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
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
|
package main
import (
"database/sql"
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
)
// ============================================================================
// Contact flag constants
// ============================================================================
const (
flagHidden = "hidden"
flagImportant = "important"
flagSpam = "spam"
)
// Where spam reports go. Both names are German words rather than translations
// of them, which is easy to get wrong: the Beschwerdestelle's English-language
// page translates its prose but still lists these same two addresses. mailweb
// previously sent to general-spam@ and specific-spam@, which read like
// plausible English renderings and are not addresses anyone published.
//
// https://www.internet-beschwerdestelle.de/beschwerde-einreichen/e-mail-spam/
//
// That category is handled by forwarded mail only; there is no web form to
// fall back on, which is why a refused send leaves nothing but the manual
// route.
const (
reportAddressGeneral = "allgemeiner-spam@internet-beschwerdestelle.de"
reportAddressIllegal = "besonderer-spam@internet-beschwerdestelle.de"
)
// setContactFlag inserts a flag for an address, ignoring if already set.
func setContactFlag(db *sql.DB, address, flag string) error {
_, err := db.Exec(
`INSERT OR IGNORE INTO contact_flags(address, flag, marked_at) VALUES (?, ?, ?)`,
address, flag, time.Now().Unix(),
)
return err
}
// clearContactFlag removes a specific flag for an address.
func clearContactFlag(db *sql.DB, address, flag string) error {
_, err := db.Exec(
`DELETE FROM contact_flags WHERE address = ? AND flag = ?`,
address, flag,
)
return err
}
// loadContactFlags returns a set of (address → set of flags) for the given flags.
func loadContactFlags(db *sql.DB, flags ...string) (map[string]map[string]bool, error) {
placeholders := make([]string, len(flags))
args := make([]any, len(flags))
for i, f := range flags {
placeholders[i] = "?"
args[i] = f
}
query := `SELECT address, flag FROM contact_flags WHERE flag IN (` +
strings.Join(placeholders, ",") + `)`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("load contact flags: %w", err)
}
defer rows.Close()
result := make(map[string]map[string]bool)
for rows.Next() {
var address, flag string
if err := rows.Scan(&address, &flag); err != nil {
return nil, fmt.Errorf("scan flag: %w", err)
}
if result[address] == nil {
result[address] = make(map[string]bool)
}
result[address][flag] = true
}
return result, rows.Err()
}
// ============================================================================
// Templates
// ============================================================================
//go:embed templates/contacts.html
var contactsTmplSrc string
//go:embed templates/contact_detail.html
var contactDetailTmplSrc string
//go:embed templates/report_spam.html
var reportSpamTmplSrc string
//go:embed templates/contact_settings.html
var contactSettingsTmplSrc string
var (
contactsTmpl = mustParseHTML("contacts", contactsTmplSrc)
contactDetailTmpl = mustParseHTML("contactdetail", contactDetailTmplSrc)
reportSpamTmpl = mustParseHTML("reportspam", reportSpamTmplSrc)
contactSettingsTmpl = mustParseHTML("contactsettings", contactSettingsTmplSrc)
)
// ============================================================================
// Types
// ============================================================================
// contactEntry is one row in the contacts listing.
type contactEntry struct {
Address string
AddrEscaped string // URL path-escaped address for use in hrefs
// Name carries both what the account owner calls this address and what its
// mail claims, so the view can mark which is which. It replaced a bare
// display-name string taken from the last message seen — a value written by
// the sender and rendered in mailweb's own voice.
Name mailtext.Name
LastSeen time.Time
Count int
Important bool // true if flagImportant is set
}
// contactsPageData is passed to contactsTmpl.
type contactsPageData struct {
Contacts []contactEntry
ShowHidden bool // true when viewing hidden contacts
HiddenCount int // number of hidden contacts (shown in normal mode)
LLMViewURL string // this page as text; see textview.go
// ReturnURL is this listing, for the pen links to come back to. Naming a
// contact is a page away now, and returning to /contacts rather than to
// the listing actually being read would drop ?show=hidden.
ReturnURL string
}
// contactPen is what {{template "penLink"}} needs: which contact to name, and
// where the reader was when they decided to.
//
// Return exists because the pen navigates away from a listing that is meant to
// be scanned. It is a path within mailweb, built by the handler from the
// request it is serving, and it is only ever a place to link back to — never a
// redirect target for anything that writes. See handleContactSettings for why
// it is validated on the way in rather than trusted.
type contactPen struct {
AddrEscaped string
Return string
}
// Pen is the settings link for one row of the contacts listing, returning to
// the listing the row was read in.
func (c contactEntry) Pen(returnTo string) contactPen {
return contactPen{AddrEscaped: c.AddrEscaped, Return: returnTo}
}
// Pen is the settings link in the contact detail header, returning to the
// conversation it was clicked from.
func (d contactDetailData) Pen() contactPen {
return contactPen{
AddrEscaped: d.AddrEscaped,
Return: "/contact/" + d.AddrEscaped,
}
}
// contactSettingsData is passed to contactSettingsTmpl.
//
// It is everything mailweb stores about one address, which is the reason the
// page exists: the flags were spread across a star in two listings, a hide
// button, a spam badge and an unsubscribe form, and the name was a text input
// wedged beside them. Nowhere said what was known about a correspondent, and
// nowhere had room to say what a petname is.
type contactSettingsData struct {
Contact string // raw email address
// Name carries the petname and the claimed name; see contactEntry.
Name mailtext.Name
AddrEscaped string
// Sharers are the other addresses carrying this petname, sorted, empty
// when the name is unique. Rendered whenever it is non-empty rather than
// only after a write, so the notice is true whenever it is shown.
Sharers []string
Important bool
Hidden bool
Spam bool
// Self says this address is the account's own, which is the one case where
// the spam report link is not drawn: a report is filed against a stranger
// with a complaints service, and there is nobody to report oneself to.
Self bool
ListUnsubscribe string // bare mailto address for display, "" if none
ListUnsubscribeURL string // https:// URL, "" if none
// MessageCount is how much mail this contact accounts for, which is the
// one number that says whether hiding it is a small act or a large one.
MessageCount int
// Return is where the reader came from; see contactPen.
Return string
}
// contactDetailData is passed to contactDetailTmpl.
type contactDetailData struct {
Contact string // raw email address
// Name is the contact's petname and claimed name; see contactEntry.
Name mailtext.Name
AddrEscaped string // URL path-escaped address
Important bool // true if flagImportant is set
Spam bool // true if flagSpam is set
ListUnsubscribe string // bare mailto address for display, "" if none
ListUnsubscribeURL string // https:// URL, "" if none
Messages []msgRow
// Self says this address is the account's own, which changes two things:
// the page offers no way to report it as spam — there is nobody to report
// it to — and it defaults to the restricted listing below.
Self bool
// SelfOnly says the listing was restricted to mail this account sent to
// nobody but itself. False when ?all=1 asked for everything, and always
// false for somebody else's address.
SelfOnly bool
// MatchedCount is how many messages carry the address at all, which is the
// size of the listing SelfOnly is hiding. Equal to len(Messages) whenever
// nothing was restricted.
MatchedCount int
// AllURL and SelfURL are this page in its other mode, empty unless Self.
// Both carry the rest of the query string, so switching does not silently
// drop paging or the text rendering.
AllURL string
SelfURL string
// DraftsByMsg holds the unsent drafts already written against each message
// shown, so a reply in progress appears beside the message it answers.
// Empty in the text rendering, which advertises the reply route instead.
DraftsByMsg map[int64][]draft
// AttachmentsByMsg holds what is attached to each message shown. Empty in
// the text rendering, which builds its own attachment region per message.
AttachmentsByMsg map[int64]attachmentBox
LLMViewURL string // this page as text; see textview.go
}
// spamMsgRow is one selectable message shown on the report spam form.
type spamMsgRow struct {
ID int64
Subject string
Date time.Time
Checked bool // whether the checkbox starts ticked; see reportSpamPrefill
// From is the sender, resolved. It is shown per row because the candidate
// list is selected by substring and can therefore span several senders — a
// bare domain, or an address that is a prefix of another. What is reported
// is the sender of each ticked message, so the sender is part of what is
// being ticked and has to be visible, and has to be visibly a claim.
From mailtext.Name
}
// reportSpamData is passed to reportSpamTmpl.
type reportSpamData struct {
Contact string
// Name is the contact's petname and claimed name; see contactEntry. It
// matters especially here, where the page names a sender being accused.
Name mailtext.Name
AddrEscaped string
ReportTo string // address the report will be sent to
Messages []spamMsgRow
// Prefill, from the query string. The form is rendered with these already
// filled in, but nothing is sent: the submit button remains the only thing
// that sends a report. See reportSpamPrefill.
Description string // seeds the description textarea
Illegal bool // pre-ticks the illegal-content checkbox
Reason string // label of the reason to preselect, "" for the default
// Prefilled is true if any field was seeded, which the form's script uses
// to decide whether it may overwrite the textarea on load.
Prefilled bool
// SMTPEnabled says whether a report can actually be sent. Without SMTP the
// form is still rendered — it is the readable account of what would be
// reported — but it says so and offers no submit button, because a button
// that files nothing is worse than no button: the redirect that followed
// looked exactly like the one that follows a report which went out.
SMTPEnabled bool
// SendError is the sending server's refusal, verbatim, set when a submitted
// report could not be handed over. The form is redisplayed with everything
// the sender typed still in it, so the refusal costs a click rather than
// the text. Nothing was sent and nothing was recorded when this is set.
//
// It is remote text rendered into HTML, and is safe only because
// html/template escapes it: it must never be turned into template.HTML.
SendError string
}
// CheckedCount is how many messages start selected, which the form states
// alongside how many it found. The two differ exactly when the caller named a
// subset, and that is the case worth noticing before pressing send.
func (d reportSpamData) CheckedCount() int {
n := 0
for _, m := range d.Messages {
if m.Checked {
n++
}
}
return n
}
// reportSpamPrefill is the query string of the spam report form, which lets a
// caller hand the form a filled-in report without submitting it.
//
// This exists for the text/llm rendering. A model can read a message, recognise
// it as phishing and describe precisely why — but it must not be the thing that
// decides to file a complaint with a third party, because a report is
// irreversible and goes out under the account owner's name. Splitting the two
// keeps the judgement where the reading happened and the authority with the
// person: the model composes a URL, a human looks at the result and presses
// send.
//
// It is a GET with parameters rather than a POST body for the same reason: a
// URL can be opened in a browser and inspected before anything happens, while a
// POST would already be the act it is meant to authorise.
type reportSpamPrefill struct {
description string
illegal bool
reason string
// msgIDs restricts which messages start checked. Empty means "all", which
// is what a human opening the form by hand gets. A caller that names ids
// gets exactly those, so a single bad mail can be reported from a sender
// who also sends real ones.
msgIDs map[int64]bool
// explicitMsgs records whether msg= was given at all, since an empty set
// and an absent parameter mean opposite things.
explicitMsgs bool
}
// parseReportSpamPrefill reads the prefill from a request's query string.
// Unparseable values are ignored rather than rejected: the form is a draft, and
// a mangled parameter should cost the caller a field to fill in by hand, not
// the whole page.
func parseReportSpamPrefill(r *http.Request) reportSpamPrefill {
q := r.URL.Query()
p := reportSpamPrefill{
description: strings.TrimSpace(q.Get("description")),
reason: strings.TrimSpace(q.Get("reason")),
}
switch strings.ToLower(strings.TrimSpace(q.Get("illegal"))) {
case "1", "true", "on", "yes":
p.illegal = true
}
if raw, ok := q["msg"]; ok {
p.explicitMsgs = true
p.msgIDs = make(map[int64]bool, len(raw))
for _, s := range raw {
var id int64
if _, err := fmt.Sscan(strings.TrimSpace(s), &id); err != nil {
log.Printf("report spam prefill: ignoring invalid msg id %q: %v", s, err)
continue
}
p.msgIDs[id] = true
}
}
return p
}
// anySet reports whether the prefill carries anything at all, which decides
// whether the form's script may overwrite the description on load.
func (p reportSpamPrefill) anySet() bool {
return p.description != "" || p.illegal || p.reason != "" || p.explicitMsgs
}
// checked reports whether a message should start ticked. An id named in msg=
// that does not belong to this contact never reaches here: the caller only asks
// about messages the query returned, so a wrong id silently selects nothing
// rather than attaching someone else's mail to the report.
func (p reportSpamPrefill) checked(id int64) bool {
if !p.explicitMsgs {
return true
}
return p.msgIDs[id]
}
// ============================================================================
// Helpers
// ============================================================================
// contactAddress is the canonical form of an email address used as a contact
// identifier: trimmed and lower-cased.
//
// It is the only thing that identifies a contact, because contacts are not
// stored. collectContacts derives them per request by grouping messages, so the
// address string *is* the key — in URLs and in contact_flags alike, with no id
// and no row to reconcile them against.
//
// Every site that compares, stores or looks up a contact address must route
// through here. Not because trimming and lower-casing is subtle, but because it
// is the one invariant tying the write side (contact_flags) to the read side
// (collectContacts): a flag stored under any other form of the string matches no
// contact that will ever be listed, the write succeeds, and nothing anywhere
// reports the mismatch. Spelling the transformation out at each call site, as
// this replaced, made agreement a coincidence that held at eight places and was
// invisible when it stopped holding.
//
// This canonicalises, it does not authenticate. The addresses it is given come
// from the From: header or from a URL, both of which are chosen by whoever sent
// the mail or typed the link; making two such strings equal says only that they
// name the same contact, never that the contact is who it claims to be.
func contactAddress(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// noSuchContact is the body of the 404 every page that mints links to a
// contact's actions answers for an address that names none.
//
// It is one string rather than one per route because the explanation is the
// thing being served: the reader typed or followed something that looks like a
// contact and got nothing, and "404" alone does not distinguish a partial
// address from a real one that has no mail. See contactExists.
func noSuchContact(address string) string {
return "no such contact: no message in the mirror carries the address " +
address + " as sender or recipient. Contacts are derived from the " +
"messages themselves, so an address that appears in none of them names " +
"no contact — a partial address, such as a bare domain, will list " +
"messages here without being one. See /contacts for the addresses that are."
}
// contactExists reports whether some message carries this address exactly, as
// sender or as recipient — which is the test collectContacts applies, and
// therefore the test for whether /contacts will ever list it.
//
// It is not "does a query return rows". Messages are selected by substring, so
// a bare domain or any fragment of a real address returns that sender's mail;
// what it does not do is name anything the listing will show. Every page that
// mints a contact's action links out of the address it was handed has to ask
// this question first, or a typed URL becomes a set of hyperlinks that appear
// to come from mailweb — which is how a spam report came to be filed against a
// bare domain, every step after the first being a client faithfully following a
// link mailweb had given it.
//
// The From: side is checked in Go rather than in SQL because the column holds
// "Name <addr>" and only parseFromAddr knows how to get the address out of it;
// the recipient side is JSON and json_each can compare it exactly.
func contactExists(db *sql.DB, address string) (bool, error) {
like := "%" + address + "%"
rows, err := db.Query(
`SELECT from_addr FROM messages WHERE LOWER(from_addr) LIKE ?`, like)
if err != nil {
return false, fmt.Errorf("contact existence check: %w", err)
}
defer rows.Close()
for rows.Next() {
var fromAddr string
if err := rows.Scan(&fromAddr); err != nil {
return false, fmt.Errorf("contact existence check: %w", err)
}
if _, from := parseFromAddr(fromAddr); from == address {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, fmt.Errorf("contact existence check: %w", err)
}
// The rows above must be closed before this runs: the read pool is small
// and a query issued while a *sql.Rows is open waits for a connection only
// closing those rows can release. defer would run too late, so the loop is
// drained by construction — it returns or falls through.
rows.Close()
var n int
err = db.QueryRow(
`SELECT COUNT(*) FROM messages m
WHERE EXISTS (SELECT 1 FROM json_each(m.to_addrs) j
WHERE LOWER(json_extract(j.value, '$.address')) = ?)
OR EXISTS (SELECT 1 FROM json_each(m.cc_addrs) j
WHERE LOWER(json_extract(j.value, '$.address')) = ?)
OR EXISTS (SELECT 1 FROM json_each(m.bcc_addrs) j
WHERE LOWER(json_extract(j.value, '$.address')) = ?)`,
address, address, address,
).Scan(&n)
if err != nil {
return false, fmt.Errorf("contact existence check: %w", err)
}
return n > 0, nil
}
// parseFromAddr extracts (name, address) from a formatted from_addr string
// like "Name <addr@host>" or "addr@host".
//
// The address is returned canonical, so a sender parsed out of a message and an
// address taken from a URL are directly comparable.
func parseFromAddr(s string) (name, address string) {
s = strings.TrimSpace(s)
if i := strings.Index(s, "<"); i >= 0 {
name = strings.TrimSpace(s[:i])
address = contactAddress(strings.Trim(s[i:], "<> "))
return
}
address = contactAddress(s)
return
}
// parseAddrList decodes one of the stored JSON address columns (to_addrs,
// cc_addrs, bcc_addrs). A value that does not parse yields no addresses rather
// than an error: the column is written from the IMAP envelope, so a malformed
// one means a message whose recipients cannot be known, and a caller asking who
// a message went to is better served by "nobody known" than by a failed page.
func parseAddrList(js string) []addr {
if strings.TrimSpace(js) == "" {
return nil
}
var addrs []addr
if err := json.Unmarshal([]byte(js), &addrs); err != nil {
return nil
}
return addrs
}
// formatToAddrs formats a JSON to_addrs value into a human-readable string
// like "Alice <alice@example.com>, Bob <bob@example.com>".
// Falls back to the raw JSON on parse error.
func formatToAddrs(toAddrsJSON string) string {
if toAddrsJSON == "" {
return ""
}
var addrs []addr
if err := json.Unmarshal([]byte(toAddrsJSON), &addrs); err != nil {
return toAddrsJSON
}
parts := make([]string, 0, len(addrs))
for _, a := range addrs {
if a.Name != "" {
parts = append(parts, fmt.Sprintf("%s <%s>", a.Name, a.Address))
} else {
parts = append(parts, a.Address)
}
}
return strings.Join(parts, ", ")
}
// collectContacts scans all messages and returns a slice of contactEntry
// sorted by LastSeen descending. If showHidden is true, only hidden contacts
// are returned; otherwise hidden contacts are excluded.
//
// The account's own addresses are left out, because every message in the mirror
// carries one and a contact that is on all of them distinguishes nothing. The
// exception is mail the account sent to nobody but itself — see isSelfMail —
// where the account is the whole of the correspondence and leaving it out drops
// the mail from the contacts view entirely.
func collectContacts(db *sql.DB, showHidden bool) ([]contactEntry, error) {
type entry struct {
name string
lastSeen int64
count int
}
byAddr := make(map[string]*entry)
// allowSelf is set only for the one address of a self-addressed message,
// which is the sole route by which an own address becomes a contact here.
touch := func(address, name string, date int64, allowSelf bool) {
address = contactAddress(address)
if address == "" || (isMyAddress(address) && !allowSelf) {
return
}
e, ok := byAddr[address]
if !ok {
e = &entry{}
byAddr[address] = e
}
e.count++
if date > e.lastSeen {
e.lastSeen = date
if name != "" {
e.name = name
}
}
}
// Pull from_addr + all JSON address fields + date from every message.
rows, err := db.Query(
`SELECT from_addr, to_addrs, cc_addrs, bcc_addrs, date FROM messages
WHERE id NOT IN (SELECT message_id FROM message_headers WHERE name = ?)`,
forgeHeaderName,
)
if err != nil {
return nil, fmt.Errorf("query contacts: %w", err)
}
defer rows.Close()
for rows.Next() {
var fromAddr string
var toAddrs, ccAddrs, bccAddrs sql.NullString
var date int64
if err := rows.Scan(&fromAddr, &toAddrs, &ccAddrs, &bccAddrs, &date); err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
// to/cc/bcc are JSON arrays of {name, address}.
to := parseAddrList(toAddrs.String)
cc := parseAddrList(ccAddrs.String)
bcc := parseAddrList(bccAddrs.String)
// from_addr is a plain formatted string.
name, address := parseFromAddr(fromAddr)
// A note to self counts once, under the address that wrote it. Its
// recipients are by definition all this account, so touching them too
// would count one message two or three times and would additionally
// list an alias that only ever received — under a heading claiming a
// conversation the alias did not have.
if isSelfMail(fromAddr, to, cc, bcc) {
touch(address, name, date, true)
continue
}
touch(address, name, date, false)
for _, addrs := range [][]addr{to, cc, bcc} {
for _, a := range addrs {
touch(a.Address, a.Name, date, false)
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Load hidden, important, spam flags in one query.
flags, err := loadContactFlags(db, flagHidden, flagImportant, flagSpam)
if err != nil {
return nil, err
}
// Petnames are loaded whole rather than per row: they are assigned by hand,
// so the table is small against tens of thousands of messages.
petnames, err := loadPetnames(db)
if err != nil {
return nil, err
}
// Convert map to slice, filter by showHidden, sort important first then by LastSeen desc.
result := make([]contactEntry, 0, len(byAddr))
for address, e := range byAddr {
isHidden := flags[address][flagHidden]
if isHidden != showHidden {
continue
}
result = append(result, contactEntry{
Address: address,
AddrEscaped: url.PathEscape(address),
Name: resolveAddress(petnames, address, e.name),
LastSeen: time.Unix(e.lastSeen, 0),
Count: e.count,
Important: flags[address][flagImportant],
})
}
// Sort: important contacts first, then by LastSeen descending within each group.
for i := 1; i < len(result); i++ {
for j := i; j > 0; j-- {
a, b := result[j-1], result[j]
if (!a.Important && b.Important) ||
(a.Important == b.Important && b.LastSeen.After(a.LastSeen)) {
result[j-1], result[j] = result[j], result[j-1]
} else {
break
}
}
}
return result, nil
}
// ============================================================================
// HTTP handlers
// ============================================================================
func (s *server) handleContacts(w http.ResponseWriter, r *http.Request) {
showHidden := r.URL.Query().Get("show") == "hidden"
contacts, err := collectContacts(s.db.Read, showHidden)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
hiddenCount := 0
if !showHidden {
if err := s.db.Read.QueryRow(
`SELECT COUNT(*) FROM contact_flags WHERE flag = ?`, flagHidden,
).Scan(&hiddenCount); err != nil {
log.Printf("hidden count: %v", err)
}
}
if mailtext.WantsLLM(r) {
// The full listing is thousands of addresses; page it so a single
// request stays usable.
p := mailtext.ParsePaging(r, len(contacts), mailtext.DefaultLimit)
writeLLM(w, r, contactsLLMTmpl, contactsLLMData{
Contacts: mailtext.SlicePage(contacts, p),
ShowHidden: showHidden,
HiddenCount: hiddenCount,
Paging: p,
HTMLViewURL: mailtext.HTMLViewURL(r),
})
return
}
mailtext.SetAlternate(w, r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := contactsTmpl.Execute(w, contactsPageData{
Contacts: contacts,
ShowHidden: showHidden,
HiddenCount: hiddenCount,
LLMViewURL: mailtext.LLMViewURL(r),
ReturnURL: r.URL.RequestURI(),
}); err != nil {
log.Printf("contacts template: %v", err)
}
}
func (s *server) handleHideContact(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
if err := setContactFlag(s.db.Write, address, flagHidden); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/contacts", http.StatusSeeOther)
}
func (s *server) handleUnhideContact(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
if err := clearContactFlag(s.db.Write, address, flagHidden); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/contacts?show=hidden", http.StatusSeeOther)
}
func (s *server) handleStarContact(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
if err := setContactFlag(s.db.Write, address, flagImportant); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
ref := r.Header.Get("Referer")
if ref == "" {
ref = "/contacts"
}
http.Redirect(w, r, ref, http.StatusSeeOther)
}
func (s *server) handleUnstarContact(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
if err := clearContactFlag(s.db.Write, address, flagImportant); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
ref := r.Header.Get("Referer")
if ref == "" {
ref = "/contacts"
}
http.Redirect(w, r, ref, http.StatusSeeOther)
}
// handlePetname handles POST /contact/{addr}/petname, assigning the name this
// account calls an address. An empty value clears it.
//
// Unlike every other contact route this one takes a value rather than being a
// pure toggle, so it reads a form field. It is deliberately not restricted to
// addresses that name a contact, as /contact/{addr} is: naming something is not
// acting on it, a name that matches no contact simply never renders, and
// refusing would make it impossible to name an address before its first message
// arrives.
func (s *server) handlePetname(w http.ResponseWriter, r *http.Request) {
address := r.PathValue("addr")
if err := r.ParseForm(); err != nil {
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
return
}
petname := r.FormValue("petname")
if err := setPetname(s.db.Write, address, petname); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
// The settings form says where it wants to land, because that page is what
// reports a name now shared with another address — a duplicate is worth
// seeing immediately, and Referer is not something a browser guarantees to
// send. The field carries a fixed literal, not a URL: the destination is
// built here from the address that was just written, so nothing in the
// request can choose where this redirects to.
dest := "/contact/" + url.PathEscape(contactAddress(address))
if r.FormValue("return") == "settings" {
dest += "/settings"
} else if ref := r.Header.Get("Referer"); ref != "" {
dest = ref
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// safeReturn validates a ?return= value as somewhere within mailweb to link
// back to, falling back to /contacts.
//
// A path beginning with a single "/" and nothing else: no scheme, no host, and
// not "//host", which a browser reads as protocol-relative and would follow off
// this machine. The value reaches the page as an href the reader is invited to
// click, so an unchecked one turns a typed URL into a link that appears to come
// from mailweb and leads somewhere else — the same thing the 404 on
// /contact/{addr} exists to prevent, in a smaller place.
//
// Nothing that writes uses this. It is a way back to a listing, and the routes
// that set flags or names build their own destinations from the address.
func safeReturn(v string) string {
if v == "" || !strings.HasPrefix(v, "/") || strings.HasPrefix(v, "//") {
return "/contacts"
}
return v
}
// withQuery is the current URL with one parameter set, or removed when the
// value is empty.
//
// It carries the rest of the query string across, which is the whole point: a
// link that switches one thing about a page must not silently drop ?view=llm or
// the offset the reader is at, landing them on the first page of a rendering
// they did not ask for. Same rule as mailtext.LLMViewURL and ParsePaging, which
// both copy the query for the same reason.
func withQuery(r *http.Request, key, value string) string {
q := r.URL.Query()
if value == "" {
q.Del(key)
} else {
q.Set(key, value)
}
if len(q) == 0 {
return r.URL.Path
}
return r.URL.Path + "?" + q.Encode()
}
// handleContactSettings handles GET /contact/{addr}/settings: everything
// mailweb stores about one address, and the one place a petname is assigned.
//
// It answers 404 for an address that names no contact, exactly as
// /contact/{addr} does and for the same reason — the page mints the links that
// hide, star, unsubscribe and report — see contactExists.
//
// The petname route it posts to is deliberately *not* restricted that way, and
// the two are consistent rather than in tension: naming something is not acting
// on it, so a name may be assigned to an address before its first message
// arrives, by a client that knows the route. What cannot happen is mailweb
// handing somebody a page of action links for an address it knows nothing
// about.
func (s *server) handleContactSettings(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
exists, err := contactExists(s.db.Read, address)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
if !exists {
http.Error(w, noSuchContact(address), http.StatusNotFound)
return
}
petnames, err := loadPetnames(s.db.Read)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
flags, err := loadContactFlags(s.db.Read, flagHidden, flagImportant, flagSpam)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
// The claimed name comes from the most recent message this address sent,
// which is what the listing shows and therefore what the reader is naming.
// A contact that only ever appears as a recipient has none, and renders as
// a bare address — correctly, since nothing has ever claimed a name for it.
var claimed string
if err := s.db.Read.QueryRow(
`SELECT from_addr FROM messages
WHERE LOWER(from_addr) LIKE ? ORDER BY date DESC, id DESC LIMIT 1`,
"%"+address+"%",
).Scan(&claimed); err != nil && err != sql.ErrNoRows {
log.Printf("contact %s: claimed name: %v", address, err)
}
name, from := parseFromAddr(claimed)
if from != address {
// The LIKE matched a longer address that contains this one. It is not
// this contact's name, so it is not shown as one.
name = ""
}
var count int
if err := s.db.Read.QueryRow(
`SELECT COUNT(*) FROM messages
WHERE LOWER(from_addr) LIKE ?
OR CAST(to_addrs AS TEXT) LIKE ?
OR CAST(cc_addrs AS TEXT) LIKE ?
OR CAST(bcc_addrs AS TEXT) LIKE ?`,
"%"+address+"%", "%"+address+"%", "%"+address+"%", "%"+address+"%",
).Scan(&count); err != nil {
log.Printf("contact %s: message count: %v", address, err)
}
var unsub, unsubURL string
if val, ok, err := latestListUnsubscribe(s.db.Read, address); err != nil {
log.Printf("contact %s: list-unsubscribe: %v", address, err)
} else if ok {
info := parseListUnsubscribeInfo(val)
unsub = info.Mailto
if info.Mailto == "" {
unsubURL = info.URL
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := contactSettingsTmpl.Execute(w, contactSettingsData{
Contact: address,
Name: resolveAddress(petnames, address, name),
AddrEscaped: url.PathEscape(address),
Sharers: sharersOf(petnames, address),
Important: flags[address][flagImportant],
Hidden: flags[address][flagHidden],
Spam: flags[address][flagSpam],
Self: isMyAddress(address),
ListUnsubscribe: unsub,
ListUnsubscribeURL: unsubURL,
MessageCount: count,
Return: safeReturn(r.URL.Query().Get("return")),
}); err != nil {
log.Printf("contact settings template: %v", err)
}
}
func (s *server) handleContactDetail(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
// Find the list-unsubscribe mailto for this contact using the most recent
// message that has one. We use the full value (including ?subject= token)
// for sending, and the bare address for the title/display.
//
// This asks the same question the unsubscribe route does, and has to ask it
// the same way: the button rendered here POSTs there, so a header found by
// a looser rule would offer a button that the route then refuses.
var contactUnsub string // bare mailto address for display
var contactUnsubURL string // https:// URL fallback
if val, ok, err := latestListUnsubscribe(s.db.Read, address); err != nil {
log.Printf("contact list-unsubscribe query: %v", err)
} else if ok {
info := parseListUnsubscribeInfo(val)
contactUnsub = info.Mailto
if info.Mailto == "" {
contactUnsubURL = info.URL
}
}
// Petnames are loaded before the message query opens its cursor, not after.
//
// The database pool is capped at a single connection (see openDB), so a
// query issued while a *sql.Rows is still open waits for a connection that
// only closing those rows can release: the handler deadlocks outright
// rather than merely running slowly. Every load a row loop needs must
// therefore happen before the loop, or after it — never inside it.
petnames, err := loadPetnames(s.db.Read)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
// The account's own address is the one contact whose substring match is
// useless: it appears in a header of nearly every message in the mirror, so
// the unrestricted listing is the whole archive under a heading calling it
// a conversation. What is a conversation with oneself is the mail sent to
// nobody else, so that is what this page shows by default, and ?all=1 asks
// for the substring listing anyway.
//
// The restriction is applied in Go rather than in SQL, for the same reason
// contactExists checks the From: side there: from_addr holds "Name <addr>"
// and only parseFromAddr gets an address out of it, and the rule itself
// lives in one place (isSelfMail) rather than being restated as a join.
self := isMyAddress(address)
selfOnly := self && r.URL.Query().Get("all") == ""
// Match address in from_addr or JSON address fields.
// Also include messages sent to the unsubscribe address (if any).
like := "%" + address + "%"
query := `SELECT id, subject, from_addr, to_addrs, cc_addrs, bcc_addrs, mailbox,
date, display_part_mime, LENGTH(display_part), rfc822_size
FROM messages
WHERE (LOWER(from_addr) LIKE ?
OR CAST(to_addrs AS TEXT) LIKE ?
OR CAST(cc_addrs AS TEXT) LIKE ?
OR CAST(bcc_addrs AS TEXT) LIKE ?
%s
OR id IN (SELECT message_id FROM message_headers
WHERE name = 'x-mailweb-unsubscribe' AND value = ?))
AND id NOT IN (SELECT message_id FROM message_headers WHERE name = ?)
ORDER BY date DESC`
args := []any{like, like, like, like}
unsubClause := ""
if contactUnsub != "" {
unsubClause = `OR CAST(to_addrs AS TEXT) LIKE ?`
args = append(args, "%"+contactUnsub+"%")
}
args = append(args, address, forgeHeaderName)
rows, err := s.db.Read.Query(fmt.Sprintf(query, unsubClause), args...)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
var msgs []msgRow
contactName := ""
// matched is how many messages carry the address at all, which is what the
// restricted view has to report: a page showing 47 of 6112 has to say what
// the other number is, or the link offering them says only "everything".
matched := 0
for rows.Next() {
var m msgRow
var unixDate int64
var toAddrsRaw, ccAddrsRaw, bccAddrsRaw []byte
var mailbox string
var mimeType sql.NullString
var partLen, rawSize sql.NullInt64
if err := rows.Scan(&m.ID, &m.Subject, &m.FromAddr, &toAddrsRaw, &ccAddrsRaw,
&bccAddrsRaw, &mailbox, &unixDate,
&mimeType, &partLen, &rawSize); err != nil {
http.Error(w, fmt.Sprintf("scan error: %v", err), http.StatusInternalServerError)
return
}
matched++
if selfOnly && !isSelfMail(m.FromAddr,
parseAddrList(string(toAddrsRaw)),
parseAddrList(string(ccAddrsRaw)),
parseAddrList(string(bccAddrsRaw))) {
continue
}
m.MimeType = mimeType.String
m.SizeHint = mailtext.SizeHint(partLen.Int64, rawSize.Int64)
m.Date = time.Unix(unixDate, 0)
m.From = resolveDisplay(petnames, m.FromAddr)
if mailbox == sentMailbox {
m.Direction = "sent"
if toAddrsRaw != nil {
m.ToAddrs = formatToAddrs(string(toAddrsRaw))
m.To = resolveDisplays(petnames, parseAddrList(string(toAddrsRaw)))
}
} else {
m.Direction = "received"
if contactName == "" {
name, addr := parseFromAddr(m.FromAddr)
if addr == address && name != "" {
contactName = name
}
}
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
http.Error(w, fmt.Sprintf("rows error: %v", err), http.StatusInternalServerError)
return
}
// Does this address name a contact at all? See contactExists, which is the
// same question /contact/{addr}/settings asks before rendering.
//
// The rows this handler already holds answer it without another query
// whenever the contact is one that sends mail, which is the common case;
// only a recipient-only address, or one that names nothing, reaches the
// database again.
isContact := false
for _, m := range msgs {
if _, from := parseFromAddr(m.FromAddr); from == address {
isContact = true
break
}
}
if !isContact {
exists, err := contactExists(s.db.Read, address)
if err != nil {
// On error, show the page rather than deny a real contact: a
// failed lookup is not evidence of absence, and the alternative is
// a 404 on a contact reached from the listing.
log.Printf("contact %s: %v", address, err)
exists = true
}
isContact = exists
}
if !isContact {
http.Error(w, noSuchContact(address), http.StatusNotFound)
return
}
// Load list-unsubscribe mailto addresses and unsubscribe-request flags.
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)
}
unsubReqs, err := loadUnsubscribeRequests(s.db.Read, ids)
if err != nil {
log.Printf("x-mailweb-unsubscribe query: %v", err)
}
for i := range msgs {
msgs[i].ListUnsubscribe = unsubs[msgs[i].ID].Mailto
msgs[i].ListUnsubscribeURL = unsubs[msgs[i].ID].URL
msgs[i].IsUnsubscribe = unsubReqs[msgs[i].ID]
}
// Load flags for this contact.
flags, err := loadContactFlags(s.db.Read, flagImportant, flagSpam)
if err != nil {
log.Printf("contact flags query: %v", err)
}
data := contactDetailData{
Contact: address,
Name: resolveAddress(petnames, address, contactName),
AddrEscaped: url.PathEscape(address),
Self: self,
SelfOnly: selfOnly,
MatchedCount: matched,
Important: flags[address][flagImportant],
Spam: flags[address][flagSpam],
ListUnsubscribe: contactUnsub,
ListUnsubscribeURL: contactUnsubURL,
Messages: msgs,
LLMViewURL: mailtext.LLMViewURL(r),
}
if self {
data.AllURL = withQuery(r, "all", "1")
data.SelfURL = withQuery(r, "all", "")
}
if mailtext.WantsLLM(r) {
p := mailtext.ParsePaging(r, len(msgs), mailtext.DefaultLimit)
paged := data
paged.Messages = mailtext.SlicePage(msgs, p)
writeLLM(w, r, contactDetailLLMTmpl, contactDetailLLMData{
contactDetailData: paged,
Paging: p,
HTMLViewURL: mailtext.HTMLViewURL(r),
})
return
}
// Drafts are looked up after the text rendering has returned: that view
// advertises the reply route instead of mounting editors, and this is a
// query it has no use for. The ids are the ones the unsubscribe lookup
// above already gathered.
if drafts, dErr := draftsByParent(s.db.Read, ids); dErr != nil {
// Costs the editors, not the conversation.
log.Printf("contact %s: drafts by parent: %v", address, dErr)
} else {
data.DraftsByMsg = drafts
}
// Likewise after the text rendering: that view builds its own attachment
// region per message. A failure costs the notes rather than the
// conversation, and leaves every box Known=false, which says "not yet
// known" rather than claiming nothing is attached.
attsByMsg, attsScanned, aErr := loadAttachmentsBatch(s.db.Read, ids)
if aErr != nil {
log.Printf("contact %s: load attachments: %v", address, aErr)
attsByMsg, attsScanned = nil, nil
}
data.AttachmentsByMsg = attachmentBoxes(ids, attsByMsg, attsScanned)
mailtext.SetAlternate(w, r.URL.Path)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := contactDetailTmpl.Execute(w, data); err != nil {
log.Printf("contact detail template: %v", err)
}
}
// reportedMessage is one message that will actually be attached to a spam
// report: its bytes, and who it says it is from.
//
// The report is built from these rather than from the address in the URL, which
// is the difference between reporting what was attached and reporting what
// somebody typed. The URL address selects candidates with a substring match, so
// it can name a sender none of the attached messages have — and, being reused as
// an exact key, it then wrote flags under a name no contact ever carries.
//
// From here on the identifier is one that came out of a message resolved by
// primary key, which is the only handle in this whole flow that is checked
// against anything.
type reportedMessage struct {
id int64
raw []byte
// from is the canonical From: address. Unverified — see contactAddress.
from string
// fromDisplay is from_addr as stored, "Name <addr>", for the cover note.
fromDisplay string
// returnPath is the Return-Path header, "" when the message has none.
//
// It is reported alongside From: because the two disagree for the great
// majority of mail — mailing lists and SRS forwarding rewrite it — so
// printing only one of them would state a single "sender" that the message
// itself does not support.
returnPath string
}
// senders returns the distinct canonical From: addresses across a set of
// reported messages, in first-seen order.
//
// A substring selection can span several senders (a domain, or an address that
// is a prefix of another), so this is a set rather than a value, and the report
// says which ones it actually attached instead of asserting one.
func senders(msgs []reportedMessage) []string {
seen := make(map[string]bool, len(msgs))
var out []string
for _, m := range msgs {
if m.from == "" || seen[m.from] {
continue
}
seen[m.from] = true
out = append(out, m.from)
}
return out
}
// loadSpamCandidates lists the messages received from one address, which are
// what the report form offers for attaching. checked decides which start
// ticked, and differs between showing the form and redisplaying it after a
// failed send: the first reads the query string, the second what was submitted.
//
// It also returns the contact's resolved name, since the form names the sender
// in its heading — the one heading in mailweb that accuses somebody, which is
// the last place a sender-chosen display name should pass for the truth.
func (s *server) loadSpamCandidates(address string, checked func(int64) bool) ([]spamMsgRow, mailtext.Name, error) {
petnames, err := loadPetnames(s.db.Read)
if err != nil {
return nil, mailtext.Name{}, err
}
like := "%" + address + "%"
rows, err := s.db.Read.Query(
`SELECT id, subject, from_addr, date FROM messages
WHERE LOWER(from_addr) LIKE ?
AND id NOT IN (SELECT message_id FROM message_headers WHERE name = ?)
ORDER BY date DESC`,
like, forgeHeaderName,
)
if err != nil {
return nil, mailtext.Name{}, err
}
defer rows.Close()
var msgs []spamMsgRow
contactName := ""
for rows.Next() {
var id int64
var subject, fromAddr string
var unixDate int64
if err := rows.Scan(&id, &subject, &fromAddr, &unixDate); err != nil {
return nil, mailtext.Name{}, err
}
if contactName == "" {
name, addr := parseFromAddr(fromAddr)
if strings.ToLower(addr) == address && name != "" {
contactName = name
}
}
msgs = append(msgs, spamMsgRow{
ID: id,
Subject: subject,
Date: time.Unix(unixDate, 0),
Checked: checked(id),
From: resolveDisplay(petnames, fromAddr),
})
}
if err := rows.Err(); err != nil {
return nil, mailtext.Name{}, err
}
return msgs, resolveAddress(petnames, address, contactName), nil
}
// handleReportSpam handles both GET (show form) and POST (submit report).
//
// Both methods are registered to this one handler rather than split, because
// the two halves share the address, the candidate messages and the form that
// is redrawn when a send fails; separating them would mean either duplicating
// that or threading it between two functions.
func (s *server) handleReportSpam(w http.ResponseWriter, r *http.Request) {
address := contactAddress(r.PathValue("addr"))
addrEsc := url.PathEscape(address)
if r.Method == http.MethodGet {
prefill := parseReportSpamPrefill(r)
msgs, contactName, err := s.loadSpamCandidates(address, prefill.checked)
if err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
reportTo := reportAddressGeneral
if prefill.illegal {
reportTo = reportAddressIllegal
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := reportSpamTmpl.Execute(w, reportSpamData{
Contact: address,
Name: contactName,
AddrEscaped: addrEsc,
ReportTo: reportTo,
Messages: msgs,
Description: prefill.description,
Illegal: prefill.illegal,
Reason: prefill.reason,
Prefilled: prefill.anySet(),
SMTPEnabled: s.smtp.host != "",
}); err != nil {
log.Printf("report spam template: %v", err)
}
return
}
// Only GET and POST are routed here, and the GET returned above, so this
// is unreachable today. It stays because what follows sends mail and sets
// flags: if a third method were ever pointed at this handler, falling
// through to that would be the wrong way to find out.
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Without SMTP no report can be sent, and the request is refused whole
// rather than performed in part. This guard is before the flag writes
// below on purpose: filing a report is one act, and an instance that
// cannot send should not carry out the half of it that it can and then
// redirect exactly as a successful report does. Marking the sender is
// still available on its own, from the contact page.
if s.smtp.host == "" {
http.Error(w,
"SMTP not configured (missing --smtp-host): no report can be sent, "+
"and nothing was recorded. Hide or flag the sender from /contact/"+address+" instead.",
http.StatusServiceUnavailable)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, fmt.Sprintf("parse form: %v", err), http.StatusBadRequest)
return
}
description := strings.TrimSpace(r.FormValue("description"))
illegal := r.FormValue("illegal") == "on"
reason := strings.TrimSpace(r.FormValue("reason"))
selectedIDs := r.Form["msg"]
spamType := "Allgemeiner Spam (unverlangt)"
if illegal {
spamType = "Spam mit illegalen Inhalten"
}
// Fetch full RFC 5322 bytes for each selected message, along with who each
// one says it is from. The id is resolved against the primary key, so a
// message that reaches this slice provably exists and its sender was read
// out of it rather than out of the request.
var reported []reportedMessage
for _, idStr := range selectedIDs {
var msgID int64
if _, err := fmt.Sscan(idStr, &msgID); err != nil {
log.Printf("handleReportSpam: invalid msg id %q: %v", idStr, err)
continue
}
var mailboxName, fromAddr string
var uid, uidvalidity uint32
var returnPath sql.NullString
err := s.db.Read.QueryRow(
`SELECT m.mailbox, m.uid, m.uidvalidity, m.from_addr,
(SELECT h.value FROM message_headers h
WHERE h.message_id = m.id AND h.name = 'return-path'
LIMIT 1)
FROM messages m WHERE m.id = ?`, msgID,
).Scan(&mailboxName, &uid, &uidvalidity, &fromAddr, &returnPath)
if err != nil {
log.Printf("handleReportSpam: msg %d not found: %v", msgID, err)
continue
}
mbox := Mailbox{Name: mailboxName, UIDValidity: uidvalidity}
raw, err := fetchFullMessage(s.pool, mbox, uid)
if err != nil {
log.Printf("handleReportSpam: fetch msg %d: %v", msgID, err)
continue
}
_, from := parseFromAddr(fromAddr)
reported = append(reported, reportedMessage{
id: msgID,
raw: raw,
from: from,
fromDisplay: strings.TrimSpace(fromAddr),
returnPath: strings.TrimSpace(returnPath.String),
})
}
attachments := make([][]byte, 0, len(reported))
for _, m := range reported {
attachments = append(attachments, m.raw)
}
reportedSenders := senders(reported)
// Nothing was attached, so there is no message whose sender could be named
// and nothing for the recipient to act on. Reporting the URL's address here
// — as this once did — files a complaint about a string that no message in
// the mirror supports, which is the mistyped-URL case arriving at the
// complaints office instead of at the person who mistyped it.
if len(reported) == 0 {
http.Error(w,
"no message could be attached: none of the selected ids resolved to a "+
"message that could be fetched, so there is nothing to report and "+
"nothing was sent. Go back to /contact/"+addrEsc+"/report-spam and "+
"select at least one message.",
http.StatusBadRequest)
return
}
coverNote := "Gemeldete Absender (From:-Header der angehängten Nachrichten,\n" +
"nicht verifiziert — die Originalnachrichten hängen unverändert an):\n"
for _, m := range reported {
display := m.fromDisplay
if display == "" {
display = "(kein Absender)"
}
coverNote += " " + display + "\n"
if m.returnPath != "" {
coverNote += " Return-Path: " + m.returnPath + "\n"
}
}
coverNote += "Art: " + spamType + "\n" +
"Melder: " + s.fromAddr + "\n" +
"Angehängte Nachrichten: " + fmt.Sprintf("%d", len(attachments)) +
"\n\n" + description
// The subject names the sender when there is one, and says how many there
// are when the selection spans several, rather than picking one of them.
subject := "Spam-Meldung: " + strings.Join(reportedSenders, ", ")
if len(reportedSenders) > 3 {
subject = fmt.Sprintf("Spam-Meldung: %s und %d weitere",
reportedSenders[0], len(reportedSenders)-1)
}
reportTo := reportAddressGeneral
if illegal {
reportTo = reportAddressIllegal
}
var reportRaw []byte
var sendErr error
if len(attachments) > 0 {
reportRaw, sendErr = buildSpamReport(s.smtp.user, reportTo, subject, coverNote, attachments)
if sendErr == nil {
// Send via SMTP directly (already built).
auth := sasl.NewPlainClient("", s.smtp.user, s.smtp.pass)
addr := fmt.Sprintf("%s:%d", s.smtp.host, s.smtp.port)
sendErr = gosmtp.SendMailTLS(addr, auth, s.fromAddr, []string{reportTo}, strings.NewReader(string(reportRaw)))
}
} else {
// No attachments — plain text report.
reportRaw, sendErr = sendMail(s.smtp, s.fromAddr, reportTo, subject, coverNote, "text/plain", "")
}
// A refused report is not a filed one, so nothing is recorded and the form
// comes back carrying what was typed plus the server's reason. Redirecting
// here — as this once did — showed the same page as a report that had gone
// out, and left the refusal in the journal where nobody looks.
if sendErr != nil {
log.Printf("handleReportSpam: send error: %v", sendErr)
submitted := make(map[int64]bool, len(selectedIDs))
for _, idStr := range selectedIDs {
var id int64
if _, err := fmt.Sscan(strings.TrimSpace(idStr), &id); err == nil {
submitted[id] = true
}
}
msgs, contactName, dbErr := s.loadSpamCandidates(address, func(id int64) bool {
return submitted[id]
})
if dbErr != nil {
http.Error(w, fmt.Sprintf("db error: %v", dbErr), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// 502: the request was well formed and mailweb did its part; the
// upstream mail server refused it.
w.WriteHeader(http.StatusBadGateway)
if err := reportSpamTmpl.Execute(w, reportSpamData{
Contact: address,
Name: contactName,
AddrEscaped: addrEsc,
ReportTo: reportTo,
Messages: msgs,
Description: description,
Illegal: illegal,
Reason: reason,
Prefilled: true,
SMTPEnabled: true,
SendError: sendErr.Error(),
}); err != nil {
log.Printf("report spam template: %v", err)
}
return
}
// Sent. Only now is the sender marked, because the flags are bookkeeping
// about a report that exists.
//
// The flags go on the senders of the messages actually attached, not on the
// address from the URL. Those are not always the same: the URL selects
// candidates with a substring match, so a bare domain, or an address that is
// a prefix of another, selects a real sender's mail and sends a correct
// report — and then wrote the flags under a key that matches no contact
// collectContacts will ever produce. The write succeeded and the sender
// stayed visible in /contacts while being absent from /contacts?show=hidden,
// so the hiding could not even be undone through the interface.
//
// A selection spanning several senders therefore flags several contacts.
// That is what was reported, and each of them is listed in the cover note.
for _, sender := range reportedSenders {
if err := setContactFlag(s.db.Write, sender, flagSpam); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
if err := setContactFlag(s.db.Write, sender, flagHidden); err != nil {
http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError)
return
}
}
// The mail has already gone out, so a failed copy to Sent is logged and no
// more: failing the request now would report the opposite of what happened.
if reportRaw != nil {
if err := appendToSent(s.imapCreds, reportRaw); err != nil {
log.Printf("handleReportSpam: appendToSent error: %v", err)
}
}
// Redirect to a contact that was actually flagged, rather than back to the
// address in the URL. When the two differ the URL's page is the one view
// that agreed with the wrong key — it looks flags up under whatever it was
// asked about, so it showed the spam badge while /contacts did not, and the
// report looked filed correctly precisely where it was not.
http.Redirect(w, r, "/contact/"+url.PathEscape(reportedSenders[0]), http.StatusSeeOther)
}
|