1
   2
   3
   4
   5
   6
   7
   8
   9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
package main

import (
	"database/sql"
	"embed"
	"flag"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"os/exec"
	"path"
	"strconv"
	"strings"
	"time"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"

	"github.com/emersion/go-imap"
	_ "github.com/emersion/go-message/charset"
	_ "modernc.org/sqlite"
)

// ============================================================================
// Templates
// ============================================================================

//go:embed templates/index.html
var indexTmplSrc string

//go:embed templates/msgbody.html
var msgBodyTmplSrc string

// nameTmplSrc defines the {{template "name"}} partial and its styles, shared by
// every HTML view that prints a correspondent. It is parsed into each of them
// rather than copied, because marking a name as trusted or claimed is a
// security property and four copies are four chances to forget one.
//
//go:embed templates/name.html
var nameTmplSrc string

// editorTmplSrc defines the block editor's styles, its reply button and its
// script tag, shared by the three views that mount one. Parsed into each of
// them rather than copied, for the same reason the name partial is: which
// pages carry script is a property worth being able to read in one place.
//
//go:embed templates/editor.html
var editorTmplSrc string

// attachmentTmplSrc defines the {{template "attachments"}} partial and its
// styles, shared by every HTML view that shows a message. Parsed into each of
// them rather than copied, for the same reason as the two above: it renders
// sender-supplied filenames and it draws a distinction — examined versus never
// looked at — that a second copy could quietly drop.
//
//go:embed templates/attachments.html
var attachmentTmplSrc string

// mustParseHTML parses an HTML view together with the shared partials and
// mailweb's {{account}} helper.
func mustParseHTML(name, src string) *template.Template {
	return template.Must(template.Must(template.Must(template.Must(
		template.New(name).Funcs(accountTmplFuncs).
			Parse(nameTmplSrc)).Parse(editorTmplSrc)).
		Parse(attachmentTmplSrc)).Parse(src))
}

//go:embed templates/msgview.html
var msgViewTmplSrc string

//go:embed templates/calendar.html
var calendarTmplSrc string

//go:embed templates/draft.html
var draftTmplSrc string

//go:embed templates/drafts.html
var draftsTmplSrc string

// staticFS holds the block editor's script.
//
// It is the only script mailweb serves from a file rather than inlining, and
// the reason is size: the editor is several hundred lines, against the handful
// that the iframe resizer and the spam form's reason selector take. It is
// served to the outer page only. A message body never loads it and could not
// run it if it did — see "SECURITY" in mailweb(1).
//
//go:embed static
var staticFS embed.FS

var (
	indexTmpl   = mustParseHTML("index", indexTmplSrc)
	msgViewTmpl = mustParseHTML("msgview", msgViewTmplSrc)
	draftTmpl   = mustParseHTML("draft", draftTmplSrc)
	draftsTmpl  = mustParseHTML("drafts", draftsTmplSrc)

	// msgBodyTmpl wraps email body content (HTML or plain) for safe iframe display.
	// Security model (layered):
	//   - CSP meta tag: script-src 'none' blocks all JS
	//   - iframe sandbox (set in index.html): no allow-scripts
	//   - base target="_blank": all links open in new tab, never in the iframe
	msgBodyTmpl = template.Must(template.New("msgbody").Parse(msgBodyTmplSrc))

	// calendarTmpl renders an attached calendar. Like msgBodyTmpl it is a
	// framed fragment carrying its own CSP, because it displays a description
	// written by the sender; see handleAttachmentCalendar.
	//
	// safeHTML is what marks that description as markup rather than text. It is
	// only ever applied to a description that the sender declared as HTML or
	// that mailweb judged to be, and it is safe for the same reason the message
	// body is: the document forbids script and the frame withholds
	// allow-scripts. It must never be used on a value reaching an unsandboxed
	// page.
	calendarTmpl = template.Must(template.New("calendar").
			Funcs(template.FuncMap{
			"safeHTML": func(s string) template.HTML { return template.HTML(s) },
		}).Parse(calendarTmplSrc))
)

// ============================================================================
// HTTP handlers
// ============================================================================

type server struct {
	// db carries both pools; see the database type. Handlers read through
	// db.Read and write through db.Write, and the read handle is read-only at
	// the SQLite level, so the distinction is enforced rather than observed.
	db            *database
	pool          *globalPool
	smtp          smtpCreds
	imapCreds     imapCreds
	fromAddr      string
	mailwebFolder string // IMAP folder for synthetic action messages
}

type msgRow struct {
	ID       int64
	Subject  string
	FromAddr string // raw "Name <addr>" as stored; prefer From for display
	ToAddrs  string // formatted recipient list, for sent messages
	// From and To are the resolved names, carrying the petname where one is
	// assigned and marking the sender's own claim where none is. Views print
	// these; FromAddr and ToAddrs remain for the places that need the raw
	// header text, such as building a reply.
	From               mailtext.Name
	To                 []mailtext.Name
	Date               time.Time
	Direction          string // "sent" or "received"
	MimeType           string // "text/plain", "text/html", or "" if not yet fetched
	ListUnsubscribe    string // mailto: address from List-Unsubscribe header, "" if absent
	ListUnsubscribeURL string // https:// URL from List-Unsubscribe header, "" if absent
	IsUnsubscribe      bool   // true if this is a mailweb-sent unsubscribe request

	// SizeHint is a rendered annotation like " (~608K)", or "" when the size
	// is unknown. See the size hints section in textview.go.
	SizeHint string
}

// handleIndex serves the message listing. Registered as "GET /{$}", which
// matches the root and nothing below it, so the guard against catching every
// unrouted path is in the pattern rather than in the handler.
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
	rng, err := mailtext.ParseDateRange(r)
	if err != nil {
		// A filter that silently does nothing is worse than an error: the
		// client would get the whole archive back and read it as the answer to
		// a question it did not get.
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	where, whereArgs := rng.SQL("date")

	// ?test=random samples the archive to exercise the MIME renderer against a
	// broad spread of messages. Paging it makes no sense — the sample is drawn
	// fresh on every request, so a "next page" would reshuffle and could repeat
	// or skip anything — so the sample is a single page with no links onwards.
	random := r.URL.Query().Get("test") == "random"

	var total int
	if err := s.db.Read.QueryRow(
		`SELECT COUNT(*) FROM messages`+where, whereArgs...,
	).Scan(&total); err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	// The two renderings page at different sizes because a page costs different
	// amounts in each; see defaultHTMLLimit. A random sample keeps the smaller
	// size in both: it exists to spot-check the MIME renderer against a spread
	// of messages, and a spot check is not made better by being five times
	// larger. ?limit= overrides any of these.
	defaultLimit := mailtext.DefaultLimit
	if random || !mailtext.WantsLLM(r) {
		defaultLimit = defaultHTMLLimit
	}
	p := mailtext.ParsePaging(r, total, defaultLimit)

	// Paging happens in SQL rather than by slicing a full result set, as the
	// contact listings do: those are thousands of rows of envelope, this is
	// every message ever synced.
	order, offset := `ORDER BY date DESC, id DESC`, p.Offset
	if random {
		order, offset = `ORDER BY RANDOM()`, 0
		p.Next, p.Prev, p.NextCount = "", "", 0
	}
	args := append(append([]any{}, whereArgs...), p.Limit, offset)
	rows, err := s.db.Read.Query(
		`SELECT id, subject, from_addr, date,
		        LENGTH(display_part), rfc822_size
		 FROM messages`+where+` `+order+` LIMIT ? OFFSET ?`, args...)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var msgs []msgRow
	for rows.Next() {
		var m msgRow
		var unixDate int64
		var partLen, rawSize sql.NullInt64
		if err := rows.Scan(&m.ID, &m.Subject, &m.FromAddr, &unixDate,
			&partLen, &rawSize); err != nil {
			http.Error(w, fmt.Sprintf("scan error: %v", err), http.StatusInternalServerError)
			return
		}
		m.Date = time.Unix(unixDate, 0)
		m.SizeHint = mailtext.SizeHint(partLen.Int64, rawSize.Int64)
		msgs = append(msgs, m)
	}
	if err := rows.Err(); err != nil {
		http.Error(w, fmt.Sprintf("rows error: %v", err), http.StatusInternalServerError)
		return
	}

	ids := make([]int64, len(msgs))
	for i, m := range msgs {
		ids[i] = m.ID
	}
	unsubs, err := loadUnsubscribeInfo(s.db.Read, ids)
	if err != nil {
		log.Printf("list-unsubscribe query: %v", err)
	}
	petnames, err := loadPetnames(s.db.Read)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	for i := range msgs {
		msgs[i].ListUnsubscribe = unsubs[msgs[i].ID].Mailto
		msgs[i].ListUnsubscribeURL = unsubs[msgs[i].ID].URL
		msgs[i].From = resolveDisplay(petnames, msgs[i].FromAddr)
	}

	if mailtext.WantsLLM(r) {
		writeLLM(w, r, indexLLMTmpl, indexLLMData{
			Messages:    msgs,
			Paging:      p,
			Filter:      rng.Describe(),
			Random:      random,
			HTMLViewURL: mailtext.HTMLViewURL(r),
		})
		return
	}

	// A reply already in progress is shown beside the message it answers. One
	// query for the whole page, over the same ids the unsubscribe lookup used,
	// and a failure costs the editors rather than the listing: not being able
	// to say a draft exists is no reason to refuse to show the mail.
	drafts, dErr := draftsByParent(s.db.Read, ids)
	if dErr != nil {
		log.Printf("index: drafts by parent: %v", dErr)
		drafts = nil
	}

	// What is attached to each message on the page, one query rather than one
	// per message. A failure costs the notes rather than the listing, and
	// degrades to "not yet known" rather than to an assertion that nothing is
	// attached — see loadAttachmentsBatch.
	attsByMsg, attsScanned, aErr := loadAttachmentsBatch(s.db.Read, ids)
	if aErr != nil {
		log.Printf("index: load attachments: %v", aErr)
		attsByMsg, attsScanned = nil, nil
	}

	mailtext.SetAlternate(w, "/")
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := indexTmpl.Execute(w, indexPageData{
		Messages:         msgs,
		Paging:           p,
		Filter:           rng.Describe(),
		Random:           random,
		DraftsByMsg:      drafts,
		AttachmentsByMsg: attachmentBoxes(ids, attsByMsg, attsScanned),
		LLMViewURL:       mailtext.LLMViewURL(r),
	}); err != nil {
		log.Printf("index template: %v", err)
	}
}

// indexPageData is passed to templates/index.html. The template took a bare
// slice until the text-view link needed a URL alongside it.
type indexPageData struct {
	Messages []msgRow
	Paging   mailtext.Paging
	Filter   string // human-readable date range, "" when unfiltered
	Random   bool
	// DraftsByMsg holds the unsent drafts already written against each message
	// on the page, so a reply in progress is shown beside the message it
	// answers rather than only under /drafts.
	DraftsByMsg map[int64][]draft
	// AttachmentsByMsg holds what is attached to each message on the page,
	// keyed by id, so the listing can say so beside the message rather than
	// leaving it to the text rendering. See templates/attachments.html.
	AttachmentsByMsg map[int64]attachmentBox
	LLMViewURL       string
}

func (s *server) handleMessage(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")

	// Fetch display_part + display_part_mime (may be NULL if only headers were synced).
	// The envelope fields cost nothing extra here and are what the text/llm
	// rendering prints as a header block; the HTML view is framed by a page that
	// already shows them, so it ignores them.
	var (
		msgID       int64
		mailboxName string
		uid         uint32
		uidvalidity uint32
		displayPart []byte
		displayMime sql.NullString
		subject     string
		fromAddr    string
		toAddrsRaw  []byte
		unixDate    int64
		isForge     bool
	)
	// Whether this is forge notification traffic is decided in the same query
	// rather than a second one: it is only needed to suppress the spam report
	// link below, which is not worth a round-trip of its own.
	err := s.db.Read.QueryRow(
		`SELECT id, mailbox, uid, uidvalidity, display_part, display_part_mime,
		        subject, from_addr, to_addrs, date,
		        EXISTS (SELECT 1 FROM message_headers
		                WHERE message_id = messages.id AND name = ?)
		 FROM messages WHERE id = ?`, forgeHeaderName, idStr,
	).Scan(&msgID, &mailboxName, &uid, &uidvalidity, &displayPart, &displayMime,
		&subject, &fromAddr, &toAddrsRaw, &unixDate, &isForge)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	mimeType := displayMime.String // "" if NULL

	// If display_part is not yet cached, fetch it from IMAP now.
	if displayPart == nil {
		ref := msgRef{
			id:   msgID,
			mbox: Mailbox{Name: mailboxName, UIDValidity: uidvalidity},
			uid:  uid,
		}
		var fetchErr error
		displayPart, mimeType, fetchErr = fetchSingleBody(s.db.Write, s.pool, ref)
		if fetchErr != nil {
			http.Error(w, fmt.Sprintf("fetch body: %v", fetchErr), http.StatusInternalServerError)
			return
		}
	}

	if mailtext.WantsLLM(r) {
		// Forge notifications are excluded from the contacts view entirely, so
		// offering to report one as spam would lead to a page that does not
		// list it.
		reportURL := ""
		if !isForge {
			reportURL = reportSpamURL(fromAddr, msgID)
		}
		// Fetching the body above records the structure, so by this point a
		// message that had never been examined has been. A failure to load the
		// list is reported as "not known" rather than as no attachments, which
		// is the same distinction the column exists to preserve.
		atts, attsKnown, err := loadAttachments(s.db.Read, msgID)
		if err != nil {
			log.Printf("warn: load attachments for msg %d: %v", msgID, err)
			atts, attsKnown = nil, false
		}
		petnames, err := loadPetnames(s.db.Read)
		if err != nil {
			http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
			return
		}
		cals := s.loadCalendarSummaries(Mailbox{Name: mailboxName, UIDValidity: uidvalidity}, uid, atts)
		writeLLM(w, r, msgBodyLLMTmpl, msgBodyLLMData{
			// The browser rendering of a message is its own page, not this
			// route without the view parameter: that would be the fragment,
			// which has no headers and no attachment list — the very things
			// this rendering has and a reader following the link wants.
			HTMLViewURL:      fmt.Sprintf("/msg/%d/view", msgID),
			ReportURL:        reportURL,
			Attachments:      atts,
			AttachmentsKnown: attsKnown,
			CalendarByIdx:    cals,
			ID:               msgID,
			Mailbox:          mailboxName,
			Subject:          subject,
			From:             resolveDisplay(petnames, fromAddr),
			To:               resolveDisplays(petnames, parseAddrList(string(toAddrsRaw))),
			Date:             time.Unix(unixDate, 0).Format("2006-01-02 15:04"),
			MimeType:         mimeType,
			// cid: references are left alone: the part endpoint they would be
			// rewritten to serves images, which a text rendering cannot show.
			//
			// The body is returned whole: listings advertise its approximate
			// size, so a client that did not want it did not have to ask.
			Body: mailtext.RenderBody(mimeType, string(displayPart)),
		})
		return
	}

	mailtext.SetAlternate(w, "/msg/"+idStr)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")

	// display_part contains just the selected MIME part bytes — render directly.
	var body template.HTML
	switch mimeType {
	case "text/html":
		// Rewrite cid: URLs to local /msg/{id}/part/{cid} endpoints.
		rewritten := rewriteCIDURLs(string(displayPart), msgID)
		body = template.HTML(rewritten)
	default:
		body = renderPlainTextBody(string(displayPart))
	}
	if err := msgBodyTmpl.Execute(w, body); err != nil {
		log.Printf("msgbody template: %v", err)
	}
}

// msgViewData is passed to templates/msgview.html.
type msgViewData struct {
	Msg     msgRow
	To      []mailtext.Name
	Mailbox string
	Att     attachmentBox
	Drafts  []draft
	// LLMViewURL points at the text rendering of the message, which is
	// /msg/{id}?view=llm — the fragment's rendering, not this page's. The two
	// are the same information: this page is chrome around that message.
	LLMViewURL string
}

// handleMessageView serves one message as a page of its own.
// URL: /msg/{id}/view
//
// /msg/{id} is documented as a fragment intended for framing, and stays one: it
// is what the listings embed, and its whole security model is that it contains
// the sender's body and nothing else. But a reader who pastes a message URL
// into a browser gets that fragment, which has no header block, no size and no
// mention of what is attached — the last being the gap this route was added to
// close. So the fragment keeps its meaning and this page frames it.
//
// A separate route rather than sniffing Sec-Fetch-Dest on /msg/{id}: what a URL
// returns should not depend on a header a client may or may not send, and the
// two documents genuinely are different things.
//
// The text rendering of a message is /msg/{id}?view=llm, which already prints
// headers, size and attachments; there is nothing for this page to add to it,
// so ?view=llm here redirects there rather than growing a second one that could
// disagree with the first.
func (s *server) handleMessageView(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")

	if mailtext.WantsLLM(r) {
		http.Redirect(w, r, "/msg/"+idStr+"?view=llm", http.StatusFound)
		return
	}

	var (
		m           msgRow
		mailboxName string
		toAddrsRaw  []byte
		unixDate    int64
		partLen     sql.NullInt64
		rawSize     sql.NullInt64
	)
	err := s.db.Read.QueryRow(
		`SELECT id, mailbox, subject, from_addr, to_addrs, date,
		        LENGTH(display_part), rfc822_size
		 FROM messages WHERE id = ?`, idStr,
	).Scan(&m.ID, &mailboxName, &m.Subject, &m.FromAddr, &toAddrsRaw, &unixDate,
		&partLen, &rawSize)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	m.Date = time.Unix(unixDate, 0)
	m.SizeHint = mailtext.SizeHint(partLen.Int64, rawSize.Int64)

	petnames, err := loadPetnames(s.db.Read)
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}
	m.From = resolveDisplay(petnames, m.FromAddr)
	to := resolveDisplays(petnames, parseAddrList(string(toAddrsRaw)))

	ids := []int64{m.ID}
	unsubs, err := loadUnsubscribeInfo(s.db.Read, ids)
	if err != nil {
		log.Printf("msgview: list-unsubscribe query: %v", err)
	}
	m.ListUnsubscribe = unsubs[m.ID].Mailto
	m.ListUnsubscribeURL = unsubs[m.ID].URL

	// A failure to load either of these costs a note beside the message, not
	// the message: the same rule the index follows. An attachment lookup that
	// failed yields Known=false, which says "not yet known" rather than
	// asserting an absence.
	byMsg, scanned, aErr := loadAttachmentsBatch(s.db.Read, ids)
	if aErr != nil {
		log.Printf("msgview: load attachments for msg %d: %v", m.ID, aErr)
		byMsg, scanned = nil, nil
	}
	drafts, dErr := draftsByParent(s.db.Read, ids)
	if dErr != nil {
		log.Printf("msgview: drafts by parent: %v", dErr)
		drafts = nil
	}

	mailtext.SetAlternate(w, "/msg/"+idStr)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := msgViewTmpl.Execute(w, msgViewData{
		Msg:     m,
		To:      to,
		Mailbox: mailboxName,
		// The message page is the only view that frames a PDF, so it is the only
		// one that asks whether a viewer is stored.
		Att:        withViewer(attachmentBoxFor(byMsg, scanned, m.ID), pdfjsAvailable(s.db.Read)),
		Drafts:     drafts[m.ID],
		LLMViewURL: "/msg/" + idStr + "?" + mailtext.ViewParam + "=" + mailtext.ViewValue,
	}); err != nil {
		log.Printf("msgview template: %v", err)
	}
}

// loadCalendarSummaries fetches and parses every calendar attachment of one
// message, keyed by attachment position.
//
// Only the text rendering calls this. The HTML views frame a sub-request per
// calendar instead, so that a listing pays nothing until something is scrolled
// into view; a text rendering has no frames, and its reader has already asked
// for one specific message.
//
// A fetch that fails is skipped rather than reported. The summary is an extra
// on top of the attachment list, and a message must still render when the IMAP
// server declines to hand over one of its parts.
func (s *server) loadCalendarSummaries(mbox Mailbox, uid uint32, atts []attachment) map[int][]icalEvent {
	var out map[int][]icalEvent
	for _, a := range atts {
		if !isCalendarPart(a) {
			continue
		}
		data, _, err := fetchAttachment(s.pool, mbox, uid, a.PartPath)
		if err != nil {
			log.Printf("warn: fetch calendar attachment %d: %v", a.Idx, err)
			continue
		}
		cal := parseICal(data)
		if len(cal.Events) == 0 {
			continue
		}
		if out == nil {
			out = make(map[int][]icalEvent)
		}
		out[a.Idx] = cal.Events
	}
	return out
}

// handlePart serves an inline image part identified by Content-ID.
// URL: /msg/{id}/part/{cid}, where the mux has already decoded the cid.
func (s *server) handlePart(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	cid := r.PathValue("cid")

	var mailboxName string
	var uid, uidvalidity uint32
	err := s.db.Read.QueryRow(
		`SELECT mailbox, uid, uidvalidity FROM messages WHERE id = ?`, idStr,
	).Scan(&mailboxName, &uid, &uidvalidity)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	mbox := Mailbox{Name: mailboxName, UIDValidity: uidvalidity}
	data, mimeType, err := fetchPartByCID(s.pool, mbox, uid, cid)
	if err != nil {
		http.Error(w, fmt.Sprintf("fetch part: %v", err), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", mimeType)
	w.Write(data)
}

// resolveAttachment looks up one attachment of one message and answers the
// request itself if it cannot, returning ok=false once it has.
//
// Shared by the two routes that address an attachment by position — the bytes
// and the calendar summary — because the lookup carries a distinction worth
// making identically in both: an index past the end and a message whose
// structure has never been examined both produce no row, and only the second is
// fixed by opening the message.
func (s *server) resolveAttachment(w http.ResponseWriter, r *http.Request) (msgID int64, mbox Mailbox, uid uint32, att attachment, ok bool) {
	idStr := r.PathValue("id")
	idx, err := strconv.Atoi(r.PathValue("idx"))
	if err != nil || idx < 1 {
		http.Error(w, "invalid attachment index", http.StatusBadRequest)
		return
	}

	var mailboxName string
	var uidvalidity uint32
	err = s.db.Read.QueryRow(
		`SELECT id, mailbox, uid, uidvalidity FROM messages WHERE id = ?`, idStr,
	).Scan(&msgID, &mailboxName, &uid, &uidvalidity)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	att, err = loadAttachment(s.db.Read, msgID, idx)
	if err == sql.ErrNoRows {
		// Either the index is past the end, or this message's structure has
		// never been examined. The two are worth distinguishing: the second is
		// answered by opening the message, which records it.
		_, scanned, lerr := loadAttachments(s.db.Read, msgID)
		if lerr == nil && !scanned {
			http.Error(w,
				"attachments of this message are not yet known; open /msg/"+idStr+" first, or run --analyze",
				http.StatusNotFound)
			return
		}
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	return msgID, Mailbox{Name: mailboxName, UIDValidity: uidvalidity}, uid, att, true
}

// handleAttachment serves one attachment of a message, addressed by its
// position in the recorded attachment list.
// URL: /msg/{id}/attachment/{idx}
//
// The bytes are served exactly as they arrived, with the type the server
// reported. What is served here is never a rendering of the attachment: the
// summary at /calendar is a separate route precisely so that this one stays a
// faithful copy of the part, which is what a reader saving a file wants.
func (s *server) handleAttachment(w http.ResponseWriter, r *http.Request) {
	msgID, mbox, uid, att, ok := s.resolveAttachment(w, r)
	if !ok {
		return
	}
	_ = msgID

	data, mimeType, err := fetchAttachment(s.pool, mbox, uid, att.PartPath)
	if err != nil {
		http.Error(w, fmt.Sprintf("fetch attachment: %v", err), http.StatusInternalServerError)
		return
	}

	if mimeType == "" {
		mimeType = att.MimeType
	}
	w.Header().Set("Content-Type", mimeType)
	// The filename is attacker-controlled, so it is quoted and stripped of any
	// path component (see attachmentFilename) before being echoed back. The
	// disposition is always "attachment": these are never rendered inline, so a
	// text/html attachment cannot execute in the origin.
	if name := att.DisplayName(); name != "" {
		w.Header().Set("Content-Disposition",
			fmt.Sprintf("attachment; filename=%q", sanitiseFilename(name)))
	}
	w.Write(data)
}

// handleAttachmentCalendar renders an attached iCalendar file as a summary.
// URL: /msg/{id}/attachment/{idx}/calendar
//
// A fragment, framed by the views that list attachments, for two reasons.
//
// The first is safety. An event's description is free text from whoever sent
// the mail and frequently contains markup — a third of the ones here do,
// including the check-in link that is the whole point of a train booking.
// Rendering it usefully means rendering sender HTML, which belongs in a
// document under script-src 'none' inside a sandbox without allow-scripts,
// exactly like a message body. That is what this fragment is.
//
// The second is cost. Attachments are never cached, so a summary is an IMAP
// round-trip, and the contact view is not paged: one carsharing sender accounts
// for 59 calendar attachments on a single page. Rendered inline that is a
// minute of serial fetching before anything appears. As lazily-loaded frames
// the browser issues them in parallel, after the page is already up, and only
// for the ones scrolled into view.
func (s *server) handleAttachmentCalendar(w http.ResponseWriter, r *http.Request) {
	_, mbox, uid, att, ok := s.resolveAttachment(w, r)
	if !ok {
		return
	}

	// Only parts that claim to be calendars, by type or by name. Anything else
	// would be this route asserting a shape the part never had.
	if !isCalendarPart(att) {
		http.Error(w, "not a calendar attachment", http.StatusNotFound)
		return
	}

	data, _, err := fetchAttachment(s.pool, mbox, uid, att.PartPath)
	if err != nil {
		http.Error(w, fmt.Sprintf("fetch attachment: %v", err), http.StatusInternalServerError)
		return
	}

	cal := parseICal(data)
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := calendarTmpl.Execute(w, cal); err != nil {
		log.Printf("calendar template: %v", err)
	}
}

// handleAttachmentInline serves a PDF attachment for display rather than for
// saving, so that reading an invoice does not mean leaving the browser.
// URL: /msg/{id}/attachment/{idx}/inline
//
// A separate route from the bytes, for the same reason the calendar summary is
// one: /attachment/{n} is a faithful copy of the part, served with the type the
// server reported and a disposition of attachment, and that promise is what
// makes it safe for every part regardless of type. This route breaks both
// halves of it deliberately — it overrides the type and asks the browser to
// render — so it is a different URL that applies to a narrow class of part,
// rather than a flag that changes what the general one does.
//
// Rendering is the *browser's*, never mailweb's. Nothing here parses the
// container; the bytes are checked for their magic number and handed over. That
// is a different proposition from reading a format, which is the line
// mailweb(7) draws around iCalendar: the code that interprets a PDF here is the
// same code that would interpret it after a download, run by the same browser,
// except that it stays in a tab the reader already has open.
//
// Three headers make that delegation safe:
//
//   - Content-Type: application/pdf, asserted only after looksLikePDF agreed,
//     because 47 of the PDFs in this archive are labelled octet-stream by their
//     senders and no browser displays that.
//   - X-Content-Type-Options: nosniff, so the browser renders what the header
//     says or nothing at all. Without it a part that passed the magic check but
//     is served to an older sniffing browser could be re-guessed as HTML.
//   - Content-Security-Policy: sandbox, which puts the response in an opaque
//     origin even when it is opened as a top-level document. A PDF viewer is
//     script, so it cannot be denied script the way a message body is; what it
//     is denied instead is the origin. Nothing served here can read a cookie of
//     mailweb's, reach its DOM or issue a same-origin request to it.
//
// The disposition still carries the filename, so saving the displayed file
// keeps the name the sender gave it.
func (s *server) handleAttachmentInline(w http.ResponseWriter, r *http.Request) {
	_, mbox, uid, att, ok := s.resolveAttachment(w, r)
	if !ok {
		return
	}

	// Only parts that claim to be PDFs, by type or by name — the cheap check,
	// which avoids fetching a part that cannot possibly qualify.
	if !maybePDFPart(att) {
		http.Error(w, "not a PDF attachment", http.StatusNotFound)
		return
	}

	data, _, err := fetchAttachment(s.pool, mbox, uid, att.PartPath)
	if err != nil {
		http.Error(w, fmt.Sprintf("fetch attachment: %v", err), http.StatusInternalServerError)
		return
	}

	if !writeInlinePDF(w, data, att.DisplayName()) {
		return
	}
	w.Write(data)
}

// writeInlinePDF checks the bytes and, if they really are a PDF, writes the
// headers that ask a browser to display them. It reports whether the caller
// should go on to write the body.
//
// Split from the handler so the decision can be tested without an IMAP server.
// That is not tidiness: the byte check is what licenses the Content-Type, and
// while it lived inline it could be deleted without a single test failing —
// looksLikePDF was covered, but nothing asserted that anything called it. A
// security check nothing exercises is one that survives exactly until somebody
// simplifies it away.
func writeInlinePDF(w http.ResponseWriter, data []byte, name string) bool {
	// The sender's word decided what was offered; the bytes decide what is
	// served. A part named .pdf that is not one is refused here rather than
	// given a Content-Type mailweb made up, and the reader still has
	// /attachment/{n} for the bytes as they arrived.
	if !looksLikePDF(data) {
		http.Error(w,
			"this attachment is not a PDF, whatever it is named; fetch it as bytes instead",
			http.StatusNotFound)
		return false
	}

	w.Header().Set("Content-Type", "application/pdf")
	w.Header().Set("X-Content-Type-Options", "nosniff")
	w.Header().Set("Content-Security-Policy", "sandbox")
	// The vendored viewer fetches this URL from a frame that has allow-scripts
	// but not allow-same-origin, which makes it an opaque origin — and an
	// opaque origin is "null" for CORS, so without this header the viewer
	// cannot read the document it was pointed at.
	//
	// Granting it costs nothing that was not already given away. mailweb binds
	// to loopback and has no authentication, so anything that can reach this
	// header can already reach the route itself and every other route beside
	// it; what a wildcard here does not do is make the frame same-origin, which
	// is the property being protected. The alternative is granting the frame
	// allow-same-origin, which was measured to let it read the embedding page
	// and fetch mailweb's own pages from inside itself.
	w.Header().Set("Access-Control-Allow-Origin", "*")
	if name != "" {
		w.Header().Set("Content-Disposition",
			fmt.Sprintf("inline; filename=%q", sanitiseFilename(name)))
	}
	return true
}

// handlePdfjsAsset serves one file of the vendored PDF viewer.
// URL: /static/pdfjs/{path...}
//
// The assets live in SQLite rather than in the binary because they are fetched
// at runtime and updated without a rebuild; see pdfjs.go for why they are
// vendored at all.
//
// Caching follows source-forge's tarball route. The URL is mutable — an update
// changes what /static/pdfjs/build/pdf.mjs returns — so it must be revalidated
// rather than cached outright, and the generation makes that cheap: it is a
// monotonic stamp of the stored bytes, so a conditional GET is answered 304
// without reading the blob. A browser loading a message pays a handful of
// small conditional requests instead of 4.7MB.
//
// Assets are read whole rather than streamed. mailweb's SQLite driver has no
// incremental blob API, and the largest file here is about 2.1MB; importing a
// second driver to stream that would cost more than it saves.
func (s *server) handlePdfjsAsset(w http.ResponseWriter, r *http.Request) {
	// The path is a key into a table, never a filesystem path: an entry that is
	// not stored is a 404, so "../" and friends have nothing to reach. It is
	// still cleaned, so that a request differing only in encoding cannot miss a
	// cache entry the equivalent request populated.
	p := strings.TrimPrefix(r.URL.Path, "/static/pdfjs/")
	if p == "" || p != path.Clean(p) {
		http.NotFound(w, r)
		return
	}

	asset, gen, err := loadPdfjsAsset(s.db.Read, p)
	if err == sql.ErrNoRows {
		http.NotFound(w, r)
		return
	}
	if err != nil {
		http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
		return
	}

	etag := fmt.Sprintf("%q", fmt.Sprintf("pdfjs-gen%d-%s", gen, p))
	w.Header().Set("ETag", etag)
	w.Header().Set("Content-Type", asset.MimeType)
	w.Header().Set("X-Content-Type-Options", "nosniff")
	// The viewer is framed with allow-scripts but not allow-same-origin, so its
	// document is an opaque origin and every asset it pulls — the worker, the
	// stylesheet, the toolbar icons, the standard fonts — is a cross-origin
	// request from "null". Without this they are blocked, which shows up as a
	// viewer that half-renders and a console full of CORS errors.
	//
	// The same reasoning as the inline route: mailweb binds to loopback and has
	// no authentication, so a wildcard here concedes nothing that reaching the
	// port did not already concede, and it is what buys the frame its opaque
	// origin instead of allow-same-origin.
	w.Header().Set("Access-Control-Allow-Origin", "*")
	// Must revalidate: this URL carries no generation, so what it returns
	// changes when a new release is ingested.
	w.Header().Set("Cache-Control", "no-cache")

	if match := r.Header.Get("If-None-Match"); match != "" && etagMatch(match, etag) {
		w.WriteHeader(http.StatusNotModified)
		return
	}
	w.Write(asset.Content)
}

// sanitiseFilename removes what must not appear in a Content-Disposition
// header: quotes and backslashes would end the quoted string early, and control
// characters would split the header. What remains is a name, not a path.
func sanitiseFilename(s string) string {
	return strings.Map(func(r rune) rune {
		switch {
		case r == '"' || r == '\\':
			return '_'
		case r < 0x20 || r == 0x7f:
			return -1
		}
		return r
	}, s)
}

// ============================================================================
// main
// ============================================================================

// resolvePassword returns the password from either --imap-pass (literal) or
// --imap-pass-cmd (shell command whose stdout is the password).
// Exactly one must be set.
func resolvePassword(pass, passCmd string) (string, error) {
	if pass != "" && passCmd != "" {
		return "", fmt.Errorf("only one of --imap-pass and --imap-pass-cmd may be set")
	}
	if pass != "" {
		return pass, nil
	}
	if passCmd != "" {
		out, err := exec.Command("sh", "-c", passCmd).Output()
		if err != nil {
			return "", fmt.Errorf("--imap-pass-cmd %q failed: %w", passCmd, err)
		}
		return strings.TrimRight(string(out), "\r\n"), nil
	}
	return "", fmt.Errorf("one of --imap-pass or --imap-pass-cmd is required")
}

// stringList implements flag.Value for a repeatable string flag, used by
// --mailbox and --my-address. It was named mailboxList while there was one of
// those; the shape is the same and the second use is what made the old name
// misleading rather than merely specific.
type stringList []string

func (m *stringList) String() string { return strings.Join(*m, ", ") }
func (m *stringList) Set(v string) error {
	*m = append(*m, v)
	return nil
}

var defaultMailboxes = []string{"INBOX", "Archive", "Archive/2020", "IfBored", "Scroll", "MustRead"}

func main() {
	imapHost := flag.String("imap-host", "", "IMAP server hostname (required)")
	imapPort := flag.Int("imap-port", 993, "IMAP server port")
	imapUser := flag.String("imap-user", "", "IMAP username (required)")
	imapPass := flag.String("imap-pass", "", "IMAP password (plaintext)")
	imapPassCmd := flag.String("imap-pass-cmd", "", "Shell command whose stdout is the IMAP password (e.g. \"pass email/foo\")")
	smtpHost := flag.String("smtp-host", "", "SMTP server hostname (optional; enables POST /send)")
	smtpPort := flag.Int("smtp-port", 465, "SMTP server port (implicit TLS)")
	smtpUser := flag.String("smtp-user", "", "SMTP username (defaults to --imap-user if empty)")
	smtpPass := flag.String("smtp-pass", "", "SMTP password (plaintext)")
	smtpPassCmd := flag.String("smtp-pass-cmd", "", "Shell command whose stdout is the SMTP password")
	fromAddr := flag.String("from", "", "From address for sent mail (defaults to --imap-user)")
	var myAddressFlags stringList
	flag.Var(&myAddressFlags, "my-address", "An address belonging to this account, excluded from the contacts listing and from reply recipients (repeatable; the first is the sender, defaults to --imap-user)")
	accountNameFlag := flag.String("account-name", "", "Short label naming this account in page titles and text renderings (defaults to --imap-user)")
	sentMailboxFlag := flag.String("sent-mailbox", "Sent", "Name of the mailbox holding sent mail, used to tell outgoing from incoming in the contact view")
	dbPath := flag.String("db", "./mailweb.db", "Path to SQLite database")
	listen := flag.String("listen", "localhost:8080", "HTTP listen address")
	analyze := flag.Bool("analyze", false, "Fetch BODYSTRUCTURE for all messages and print MIME frequency table, then exit")
	listMailboxes := flag.Bool("list-mailboxes", false, "List all mailboxes on the server and exit")
	var mailboxFlags stringList
	flag.Var(&mailboxFlags, "mailbox", "Mailbox to sync and watch (repeatable); defaults to INBOX Archive Archive/2020 IfBored Scroll MustRead")
	mailwebFolder := flag.String("mailweb-folder", "mailweb", "IMAP folder for synthetic mailweb action messages")
	pdfjsUpdate := flag.Bool("pdfjs-update", true, "Check GitHub at startup for a newer pdf.js and store it in the database; with this off, whatever is already stored is used")
	flag.Parse()

	if *imapHost == "" || *imapUser == "" {
		log.Fatal("--imap-host and --imap-user are required")
	}

	// The account identity is resolved here, before anything can render or
	// query: every page names the account and one query excludes its address,
	// so a request served with these still empty would quietly claim to be
	// nobody's mail. Both default to the login name, which is the address for
	// most accounts and a truthful label for the rest.
	//
	// The first --my-address is the primary one and is what a reply is sent
	// from; the rest are aliases, recognised as this account wherever the
	// question is "is this me" but never used as a sender. A mailbox commonly
	// receives at more than one address, and an alias mailweb does not know
	// about is one that survives reply-all's filter and ends up in the To:
	// header of the account's own reply.
	for i, a := range myAddressFlags {
		address := contactAddress(a)
		if address == "" {
			continue
		}
		if i == 0 {
			myAddress = address
		}
		myAddresses[address] = true
	}
	if myAddress == "" {
		myAddress = contactAddress(*imapUser)
	}
	myAddresses[myAddress] = true
	accountName = strings.TrimSpace(*accountNameFlag)
	if accountName == "" {
		accountName = *imapUser
	}
	sentMailbox = strings.TrimSpace(*sentMailboxFlag)

	password, err := resolvePassword(*imapPass, *imapPassCmd)
	if err != nil {
		log.Fatalf("password: %v", err)
	}

	// openDB creates and migrates; the read pool is opened afterwards because
	// mode=ro cannot create the file on a fresh install.
	db, err := openDB(*dbPath)
	if err != nil {
		log.Fatalf("open db: %v", err)
	}
	defer db.Close()

	readDB, err := openReadPool(*dbPath, readPoolSize)
	if err != nil {
		log.Fatalf("open read pool: %v", err)
	}
	defer readDB.Close()

	dbs := &database{Read: readDB, Write: db}

	creds := imapCreds{
		host: *imapHost,
		port: *imapPort,
		user: *imapUser,
		pass: password,
	}

	if *listMailboxes {
		c, err := connectIMAP(creds)
		if err != nil {
			log.Fatalf("connect: %v", err)
		}
		defer c.Logout()
		mboxes := make(chan *imap.MailboxInfo, 50)
		done := make(chan error, 1)
		go func() { done <- c.List("", "*", mboxes) }()
		for m := range mboxes {
			fmt.Println(m.Name)
		}
		if err := <-done; err != nil {
			log.Fatalf("list: %v", err)
		}
		return
	}

	if *analyze {
		if err := analyzeBodyStructures(db, creds); err != nil {
			log.Fatalf("analyze: %v", err)
		}
		return
	}

	mailboxes := []string(mailboxFlags)
	if len(mailboxes) == 0 {
		mailboxes = defaultMailboxes
	}
	// Ensure the synthetic folder exists and add it to the watched list.
	if err := ensureMailboxExists(creds, *mailwebFolder); err != nil {
		log.Printf("mailweb: could not create folder %q: %v", *mailwebFolder, err)
	}
	mailboxes = append(mailboxes, *mailwebFolder)
	log.Printf("mailweb: watching mailboxes: %v", mailboxes)

	if err := backfillHeaders(db); err != nil {
		log.Fatalf("header backfill: %v", err)
	}

	// Single global pool shared by backfill, idle fetches, and HTTP serving.
	pool := newGlobalPool(creds, 3, 25)
	defer pool.close()

	if err := initialSync(db, creds, pool, mailboxes); err != nil {
		log.Fatalf("imap sync: %v", err)
	}

	// fetchCh carries Mailbox signals from idle goroutines to the fetch goroutine,
	// reconcileCh the same for expunges. Both are buffered at len(mailboxes) so
	// every mailbox can have a signal queued without blocking its idle loop.
	fetchCh := make(chan Mailbox, len(mailboxes))
	reconcileCh := make(chan Mailbox, len(mailboxes))
	for _, name := range mailboxes {
		go idleLoop(creds, db, name, fetchCh, reconcileCh)
	}
	go fetchLoop(creds, db, fetchCh)
	go reconcileLoop(db, pool, reconcileCh)

	// Resolve SMTP credentials (optional — only needed for POST /send).
	var sc smtpCreds
	if *smtpHost != "" {
		smtpPassword, err := resolvePassword(*smtpPass, *smtpPassCmd)
		if err != nil {
			log.Fatalf("smtp password: %v", err)
		}
		smtpUsername := *smtpUser
		if smtpUsername == "" {
			smtpUsername = *imapUser
		}
		sc = smtpCreds{
			host: *smtpHost,
			port: *smtpPort,
			user: smtpUsername,
			pass: smtpPassword,
		}
		log.Printf("mailweb: SMTP enabled (%s:%d as %s)", sc.host, sc.port, sc.user)
	}

	from := *fromAddr
	if from == "" {
		from = *imapUser
	}

	srv := &server{db: dbs, pool: pool, smtp: sc, imapCreds: creds, fromAddr: from, mailwebFolder: *mailwebFolder}

	// The PDF viewer is brought up to date in the background, deliberately not
	// on the path to serving. It is a 6MB download from GitHub, and mail must
	// be readable while it happens and readable at all when it fails — a
	// machine with no network still has its whole mirror on disk. Until it
	// finishes, a PDF is linked rather than framed, which is what the feature
	// did before the viewer existed.
	if *pdfjsUpdate {
		go pdfjsSync(dbs.Write)
	} else {
		log.Printf("pdfjs: update check disabled")
	}

	log.Printf("mailweb: serving on http://%s", *listen)
	if err := http.ListenAndServe(*listen, srv.routes()); err != nil {
		log.Fatalf("serve: %v", err)
	}
}

// routes is the whole routing table, and is meant to be readable as one: what
// exists, which method reaches it, and which parts of the path are values.
//
// It used to be nine prefix patterns feeding dispatchers that took the path
// apart with TrimPrefix, TrimSuffix and HasSuffix, so the set of routes was
// spread across four files and could only be recovered by reading all of them.
// The method check was a line at the top of each handler, which meant it could
// be — and in two cases was — simply missing.
//
// Two things follow from letting the pattern do the work. A request with the
// wrong method is answered 405 by the mux rather than by whichever handler
// remembered to look, and a {wildcard} arrives already percent-decoded, so the
// url.PathUnescape every contact handler repeated is gone along with the
// "invalid address" branch after it. Addresses still pass through
// contactAddress, which is normalisation and not decoding.
//
// It is a method returning the mux, rather than inline in main, so that the
// table can be exercised without an IMAP server: main cannot reach the point
// of serving without one, and a routing table nothing can test is one that
// only breaks in production.
func (s *server) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", s.handleIndex)

	mux.HandleFunc("GET /msg/{id}", s.handleMessage)
	// /msg/{id} is the fragment the listings frame; /view is the page a person
	// lands on. See handleMessageView.
	mux.HandleFunc("GET /msg/{id}/view", s.handleMessageView)
	mux.HandleFunc("GET /msg/{id}/part/{cid}", s.handlePart)
	mux.HandleFunc("GET /msg/{id}/attachment/{idx}", s.handleAttachment)
	// The summary of a calendar attachment, as a framed fragment. The bytes
	// stay at the route above; this one renders them.
	mux.HandleFunc("GET /msg/{id}/attachment/{idx}/calendar", s.handleAttachmentCalendar)
	// The same part, asked to be displayed rather than saved. Also beside the
	// bytes rather than replacing them: this one overrides the sender's type
	// and only ever answers for a part whose bytes really are a PDF.
	mux.HandleFunc("GET /msg/{id}/attachment/{idx}/inline", s.handleAttachmentInline)
	mux.HandleFunc("POST /msg/{id}/reply", s.handleReply)
	// Registered so that the refusal can explain itself. Without it the mux
	// answers a bare 405, and the reason a GET is refused here — a draft is
	// created, not filled in, so a link or a prefetch would litter the
	// database — is exactly what somebody arriving by GET needs to be told.
	mux.HandleFunc("GET /msg/{id}/reply", s.handleReply)

	mux.HandleFunc("GET /contacts", s.handleContacts)
	mux.HandleFunc("GET /contact/{addr}", s.handleContactDetail)
	mux.HandleFunc("POST /contact/{addr}/hide", s.handleHideContact)
	mux.HandleFunc("POST /contact/{addr}/unhide", s.handleUnhideContact)
	mux.HandleFunc("POST /contact/{addr}/star", s.handleStarContact)
	mux.HandleFunc("POST /contact/{addr}/unstar", s.handleUnstarContact)
	mux.HandleFunc("GET /contact/{addr}/settings", s.handleContactSettings)
	mux.HandleFunc("POST /contact/{addr}/petname", s.handlePetname)
	mux.HandleFunc("GET /contact/{addr}/report-spam", s.handleReportSpam)
	mux.HandleFunc("POST /contact/{addr}/report-spam", s.handleReportSpam)

	mux.HandleFunc("GET /forge", s.handleForge)
	mux.HandleFunc("GET /forge/{repo}", s.handleForgeRepo)
	// A {wildcard} does not match an empty segment, so a trailing slash would
	// otherwise 404. It is a truncated URL, and the listing is where it was
	// going; the same courtesy the prefix-matched route used to extend.
	mux.HandleFunc("GET /forge/{$}", s.handleForgeRepo)

	mux.HandleFunc("GET /drafts", s.handleDrafts)
	mux.HandleFunc("GET /draft/{token}", s.handleDraftView)
	mux.HandleFunc("POST /draft/{token}/send", s.handleDraftSend)
	mux.HandleFunc("POST /draft/{token}/discard", s.handleDraftDiscard)

	// The editor's API. Everything here is inert: it writes to the draft
	// tables and nothing leaves the machine. Sending is above, is a form post
	// from the draft's own page, and is deliberately not part of this.
	mux.HandleFunc("GET /api/drafts/{token}", s.handleAPIDraftGet)
	mux.HandleFunc("PATCH /api/drafts/{token}", s.handleAPIDraftPatch)
	mux.HandleFunc("POST /api/drafts/{token}/blocks", s.handleAPIBlockCreate)
	mux.HandleFunc("PATCH /api/drafts/{token}/blocks/{block}", s.handleAPIBlockPatch)
	mux.HandleFunc("POST /api/drafts/{token}/blocks/{block}/move", s.handleAPIBlockMove)
	mux.HandleFunc("DELETE /api/drafts/{token}/blocks/{block}", s.handleAPIBlockDelete)

	mux.Handle("GET /static/", http.FileServerFS(staticFS))
	// The vendored PDF viewer, which lives in the database rather than in the
	// binary. Registered after the embedded tree and more specific than it, so
	// the mux prefers it; the two must not be merged, because one is built into
	// the binary and the other is fetched and replaced at runtime.
	mux.HandleFunc("GET /static/pdfjs/", s.handlePdfjsAsset)

	mux.HandleFunc("POST /send", s.handleSend)
	mux.HandleFunc("POST /unsubscribe/{id}", s.handleUnsubscribe)
	mux.HandleFunc("POST /unsubscribe/contact/{addr}", s.handleUnsubscribeContact)

	return mux
}

// handleSend accepts POST /send with form fields: to, subject, body.
// It sends the mail via SMTP and appends a copy to the IMAP Sent mailbox.
// Returns 200 on success, 4xx/5xx on error.
func (s *server) handleSend(w http.ResponseWriter, r *http.Request) {
	if s.smtp.host == "" {
		http.Error(w, "SMTP not configured (missing --smtp-host)", http.StatusServiceUnavailable)
		return
	}

	if err := r.ParseForm(); err != nil {
		http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
		return
	}
	to := strings.TrimSpace(r.FormValue("to"))
	subject := r.FormValue("subject")
	body := r.FormValue("body")
	contentType := r.FormValue("bodytype") // "text/html" or "text/plain" (default)
	if to == "" {
		http.Error(w, "missing 'to' field", http.StatusBadRequest)
		return
	}

	raw, err := sendMail(s.smtp, s.fromAddr, to, subject, body, contentType, "")
	if err != nil {
		log.Printf("handleSend: sendMail error: %v", err)
		http.Error(w, fmt.Sprintf("send error: %v", err), http.StatusInternalServerError)
		return
	}

	// Best-effort IMAP APPEND — log but don't fail the request if it errors.
	if err := appendToSent(s.imapCreds, raw); err != nil {
		log.Printf("handleSend: appendToSent error: %v", err)
	}

	fmt.Fprintf(w, "sent to %s\n", to)
}