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)
}
|