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
|
package main
// Reconstructing a thread from the headers that claim one.
//
// A thread is not a thing mail carries. What a message carries is an assertion
// about one other message — In-Reply-To names a parent — and a thread is what
// you get by following those assertions until they stop. Every step of that is
// somebody else's data: the identifiers are written by the sending client, they
// are not authenticated, they are not unique, and a great many of them point at
// mail this mirror does not have.
//
// So this file reconstructs threads and is written throughout as though the
// reconstruction were wrong, because on this author's archive it demonstrably is
// a minority of the time:
//
// - 13199 messages name a parent that is here, and can be linked.
// - 6076 name a parent that is not, and say so rather than appearing to begin
// a thread. That is nearly a third of everything that claims a parent, and
// is what the mirror's two-year backfill window costs.
// - 9798 name no parent at all.
// - 71 carry no Message-ID, so nothing can ever point at them and they can
// never be a parent. They are not broken, they are unaddressable.
// - One Message-ID in this archive names three different rows and several name
// two, from the same message filed in two mailboxes and from senders that
// reuse an identifier. Every join here therefore expects several rows and
// none of it treats a Message-ID as a key.
//
// What all of that buys is strictly bounded, and the bound is the point. A
// reconstructed thread decides what is *shown* and what is *offered*; it never
// decides what is sent. Nothing in this file widens a set of recipients on its
// own — see deriveThreadSet, which builds a candidate that a person then has to
// choose by name. That is the rule in "WHAT IS OFFERED AND WHAT IS DONE" in
// mailweb(7), and threading is the feature that most wants to break it: a
// forged In-Reply-To would otherwise be a way to add an address to somebody
// else's reply.
//
// The queries all carry `AND <column> <> ''` clauses that look redundant. They
// are load-bearing: the indexes from migration014 are partial, and SQLite uses a
// partial index only where it can prove the query implies the predicate. Without
// the clause the lookup is a full scan of a 340 MB table. See migration014.
import (
"database/sql"
"fmt"
"log"
"sort"
"strings"
"time"
"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)
// threadMaxDepth bounds how far an ancestry walk will climb.
//
// Not a safety valve against deep threads — the longest References chain here
// holds 19 entries — but against a cycle. Nothing stops two messages from naming
// each other as parent: the identifiers are written by the sender, so a loop is
// a thing somebody can simply write, whether by malice or by a broken client
// reusing one Message-ID for a whole conversation. A recursive CTE meeting one
// does not return.
//
// UNION rather than UNION ALL in the walks already collapses a repeated
// identifier, which stops the common case; the depth cap is what handles a cycle
// long enough to keep producing new pairs.
const threadMaxDepth = 50
// threadMaxRecipients bounds how many addresses a thread-derived recipient set
// may name before it is refused outright.
//
// It is refused rather than truncated, which is the whole reason the constant
// exists. This archive holds a 473-message thread, and a set built from it would
// name more people than anybody intends to write to in one act. Silently
// sending to the first 25 of them would be the exact failure parseRecipients was
// written to prevent — a send that reached some of its addresses, with the rest
// dropped, and nothing in the result saying so.
//
// 25 is a judgement informed by a measurement. Over a sample of 3000 replies
// from this archive, every one produced a thread set, the widest named 61
// addresses, and 10 — 0.3% — came out above 25. So the cap refuses almost
// nothing while still standing between a careless press and sixty strangers.
//
// A set over it is presented as one that cannot be sent, with its size stated
// and all of its addresses still listed, so the answer is to write to the people
// meant rather than to a number mailweb chose. The refusal is enforced at the
// send route as well as on the page: a missing button is a UI fact, not a rule.
const threadMaxRecipients = 25
// threadWalkSQL is the recursive walk collectThreadAddresses runs: climb to the
// oldest reachable ancestor, descend from it, and return every message in the
// result together with the one the walk started from.
//
// A package-level constant rather than a literal inside the function, so that
// TestThreadWalkDrivesFromTheThread can ask SQLite to plan the very query that
// runs. A test that planned a copy would keep passing after the original was
// changed, which for a join-order guard is worse than having no test.
//
// Parameters, in order: the identifier to start from, the depth cap twice, and
// the id of the message the walk is for.
const threadWalkSQL = `
WITH RECURSIVE
ancestors(mid, depth) AS (
SELECT ?, 0
UNION
SELECT m.in_reply_to, a.depth + 1
FROM messages m JOIN ancestors a ON m.message_id = a.mid
WHERE m.message_id <> '' AND m.in_reply_to <> '' AND a.depth < ?
),
root(mid) AS (
SELECT mid FROM ancestors WHERE mid <> '' ORDER BY depth DESC LIMIT 1
),
tree(mid, depth) AS (
SELECT mid, 0 FROM root
UNION
SELECT m.message_id, t.depth + 1
FROM messages m JOIN tree t ON m.in_reply_to = t.mid
WHERE m.message_id <> '' AND m.in_reply_to <> '' AND t.depth < ?
)
SELECT m.id, m.from_addr, m.to_addrs, m.cc_addrs, m.date
FROM tree CROSS JOIN messages m
ON m.message_id = tree.mid AND m.message_id <> ''
WHERE tree.mid <> ''
UNION
SELECT m.id, m.from_addr, m.to_addrs, m.cc_addrs, m.date
FROM messages m
WHERE m.id = ?
ORDER BY 5 ASC, 1 ASC`
// threadRef is one message in a thread, as much of it as a link needs.
type threadRef struct {
ID int64
Subject string
Mailbox string
// From is the raw "Name <addr>" as stored. Resolved by the caller, which
// holds the petnames: see loadPetnames and why it must not be called inside
// a row loop.
FromAddr string
Date time.Time
// Outgoing records that this account wrote the message, asked of the From
// header rather than of the mailbox. See isOutgoing.
Outgoing bool
}
// threadLinks is what one message can say about its place in a thread.
//
// Deliberately shallow: one hop up and one hop down, not the whole thread. A
// message page is a page about one message, and the two questions a reader has
// there are "what is this answering" and "did anybody answer it". Rendering the
// entire thread would answer a third question nobody asked on that page, at the
// cost of a walk.
type threadLinks struct {
// Parent is the message this one replies to, nil when it claims none or
// when the one it claims is not mirrored.
Parent *threadRef
// ParentMissing records that a parent was claimed and could not be found:
// the 6076 case. It is not the same as claiming none, and a view that
// rendered both as nothing would say this message began a thread when it
// did not.
ParentMissing bool
// ParentID is the identifier that was claimed, kept when ParentMissing so a
// view can show what it was looking for. Untrusted text from a header.
ParentID string
// Replies are the mirrored messages naming this one as their parent,
// oldest first. Several are normal.
Replies []threadRef
}
// HasAny reports whether there is anything to render, so a view can omit the
// whole region rather than drawing an empty heading. A claimed-but-missing
// parent counts: saying so is the point.
func (t threadLinks) HasAny() bool {
return t.Parent != nil || t.ParentMissing || len(t.Replies) > 0
}
// scanThreadRefs reads threadRef rows from a query that selected them in the
// canonical column order.
func scanThreadRefs(rows *sql.Rows) ([]threadRef, error) {
defer rows.Close()
var out []threadRef
for rows.Next() {
var r threadRef
var unixDate int64
if err := rows.Scan(&r.ID, &r.Subject, &r.Mailbox, &r.FromAddr, &unixDate); err != nil {
return nil, fmt.Errorf("scan thread ref: %w", err)
}
r.Date = time.Unix(unixDate, 0)
r.Outgoing = isOutgoing(r.FromAddr)
out = append(out, r)
}
return out, rows.Err()
}
// loadContextParents finds the messages a listing answers but does not contain.
//
// Given the messages that matched a contact, it returns the parent of any of
// them that is in the mirror and is not itself in the listing. One hop, upwards
// only, and that restriction is the security argument rather than a saving: a
// forged In-Reply-To can then only surface a message already in this mirror, and
// cannot introduce an address, a name, or anything from outside. Fanning
// outwards to replies would let anybody who can guess a Message-ID put a message
// of their own on somebody else's contact page.
//
// The messages come back marked (see msgRow.Context), never blended in. They do
// not carry the contact's address; presenting them as part of the correspondence
// would assert something false about who wrote to whom.
//
// One query, with every parent batched into a single IN clause against the
// partial index from migration014, rather than a lookup per message. Measured on
// this author's archive: a 269-message contact page that gains 50 parents costs
// 66ms more (341ms to 407ms), and the 6315-message listing of the account's own
// address 88ms more (1.16s to 1.24s). Most contacts gain nothing at all — the
// busiest sender here, at 1570 messages, gains none — and pay only the query.
//
// A lookup per message would have been 269 round trips on that page instead of
// one, which is the difference between an index being worth having and not.
func loadContextParents(db *sql.DB, msgs []msgRow, petnames map[string]string) ([]msgRow, error) {
if len(msgs) == 0 {
return nil, nil
}
// Which parents to look for, and which ids are already on the page. Both are
// built before any query runs: the pool holds one connection, so a lookup
// issued while rows are open deadlocks.
have := make(map[int64]bool, len(msgs))
for _, m := range msgs {
have[m.ID] = true
}
wantIDs := make(map[string]int64) // parent Message-ID -> the child claiming it
for _, m := range msgs {
if m.Context {
// A context row's own parent is not pulled in: that is the second
// hop, and the listing is not a thread view.
continue
}
if m.InReplyTo == "" {
continue
}
if _, seen := wantIDs[m.InReplyTo]; !seen {
wantIDs[m.InReplyTo] = m.ID
}
}
if len(wantIDs) == 0 {
return nil, nil
}
// One query for every parent at once. The `<> ''` selects the partial index;
// see migration014.
placeholders := make([]string, 0, len(wantIDs))
args := make([]any, 0, len(wantIDs))
for id := range wantIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := `SELECT id, message_id, subject, from_addr, to_addrs, cc_addrs,
bcc_addrs, mailbox, date, display_part_mime,
LENGTH(display_part), rfc822_size
FROM messages
WHERE message_id IN (` + strings.Join(placeholders, ",") + `)
AND message_id <> ''
AND id NOT IN (SELECT message_id FROM message_headers
WHERE name = ?)
ORDER BY date ASC, id ASC`
args = append(args, forgeHeaderName)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query context parents: %w", err)
}
defer rows.Close()
var out []msgRow
seen := make(map[int64]bool)
for rows.Next() {
var (
m msgRow
messageID string
unixDate int64
toRaw []byte
ccRaw []byte
bccRaw []byte
mailbox string
mimeType sql.NullString
partLen sql.NullInt64
rawSize sql.NullInt64
)
if err := rows.Scan(&m.ID, &messageID, &m.Subject, &m.FromAddr, &toRaw,
&ccRaw, &bccRaw, &mailbox, &unixDate, &mimeType, &partLen,
&rawSize); err != nil {
return nil, fmt.Errorf("scan context parent: %w", err)
}
// Already on the page: it matched the contact in its own right, so it is
// not context and must not be listed twice.
if have[m.ID] || seen[m.ID] {
continue
}
seen[m.ID] = true
m.Context = true
m.ContextFor = wantIDs[messageID]
m.MimeType = mimeType.String
m.SizeHint = mailtext.SizeHint(partLen.Int64, rawSize.Int64)
m.Date = time.Unix(unixDate, 0)
m.From = resolveDisplay(petnames, m.FromAddr)
m.To = resolveDisplays(petnames, parseAddrList(string(toRaw)))
m.Cc = resolveDisplays(petnames, parseAddrList(string(ccRaw)))
m.Bcc = resolveDisplays(petnames, parseAddrList(string(bccRaw)))
if isOutgoing(m.FromAddr) {
m.Direction = "sent"
} else {
m.Direction = "received"
}
out = append(out, m)
}
return out, rows.Err()
}
// mergeContextParents folds context messages into a listing that is ordered
// newest first, keeping that order.
//
// A plain append would put them all at the end, below mail years older, where a
// parent would not read as the thing its reply answers. Sorting by date is what
// makes the pair adjacent, which is the whole point of fetching it.
func mergeContextParents(msgs, ctx []msgRow) []msgRow {
out := make([]msgRow, 0, len(msgs)+len(ctx))
out = append(out, msgs...)
out = append(out, ctx...)
sort.SliceStable(out, func(i, j int) bool {
if !out[i].Date.Equal(out[j].Date) {
return out[i].Date.After(out[j].Date)
}
// A parent and its reply can share a timestamp to the second. The
// parent is the lower id in any thread mailweb mirrors, and a stable
// tiebreak is what stops the pair swapping between requests.
return out[i].ID < out[j].ID
})
return out
}
// deriveThreadSet builds the candidate recipient set covering a whole thread,
// and returns false when there is no point offering one.
//
// It is omitted in the case that matters most for not being annoying: when it
// would reach nobody that setAll does not already reach. Offering the same people
// twice, once under a name suggesting it is wider, is how a person stops reading
// the labels — and the labels are the only thing standing between them and a
// reply that went further than they meant. deriveRecipientSets applies the same
// rule to setAll against setSender.
//
// Over threadMaxRecipients the set is returned with TooMany set rather than
// truncated or dropped. Truncating is the failure parseRecipients exists to
// prevent; dropping it would leave a reader who knows the thread is large unable
// to see who is on it.
func deriveThreadSet(db *sql.DB, p *parentMessage, existing map[string]recipientSet) (recipientSet, bool) {
addrs, err := collectThreadAddresses(db, p.ID)
if err != nil {
// Costs the option, never the draft. A reply that cannot be threaded can
// still be sent to the sender, which is what the other sets are for.
log.Printf("draft: collect thread addresses for msg %d: %v", p.ID, err)
return recipientSet{}, false
}
if len(addrs) == 0 {
return recipientSet{}, false
}
set := recipientSet{Name: setThread, FromMsg: make(map[string]int64)}
for _, a := range addrs {
if a.Kind == rcptCc {
set.Cc = append(set.Cc, a.Rcpt)
} else {
set.To = append(set.To, a.Rcpt)
}
// Which message put this address in the set. The whole reason the thread
// set is safe to offer: an address here may come from mail years old, and
// naming its source is what lets somebody spot the one that should not be
// there. See migration015.
set.FromMsg[a.Rcpt.Address()] = a.FromMsg
}
if set.Empty() {
return recipientSet{}, false
}
// Nothing new: the thread is one message deep, or every later message kept
// the same recipients.
if reachesNobodyNew(set, existing) {
return recipientSet{}, false
}
if set.Count() > threadMaxRecipients {
set.TooMany = true
}
return set, true
}
// reachesNobodyNew reports whether every address in set already appears in one of
// the sets given.
func reachesNobodyNew(set recipientSet, existing map[string]recipientSet) bool {
known := make(map[string]bool)
for _, other := range existing {
for _, r := range other.To {
known[r.Address()] = true
}
for _, r := range other.Cc {
known[r.Address()] = true
}
}
for _, r := range append(append([]Recipient{}, set.To...), set.Cc...) {
if !known[r.Address()] {
return false
}
}
return true
}
// threadAddress is one address found in a thread, with the message it came from.
type threadAddress struct {
Rcpt Recipient
// FromMsg is the message whose headers named this address, kept so the draft
// page can say where each recipient came from. An address that a person
// reads beside "from the mail you sent on 2026-09-18" is one they can judge;
// a bare list of nine is not. See draft_recipients.from_msg_id.
FromMsg int64
// Kind is rcptTo or rcptCc, from the header it was found in on that message.
Kind string
}
// collectThreadAddresses walks the thread a message belongs to and returns every
// address in it, with the message each came from.
//
// The walk climbs to the oldest ancestor it can reach and then descends, both
// bounded by threadMaxDepth against a cycle somebody can simply write. It is one
// recursive CTE rather than a loop of queries because the loop would be a
// round trip per message, and a 473-message thread exists here.
//
// This account's own addresses are excluded, for the reason --my-address exists:
// an own address that survives into a To: header is one every other recipient
// then writes to. The exclusion is isMyAddress, the same test the contacts
// listing and deriveRecipientSets use, so the three cannot drift.
//
// Ordering is by date and then id, so the addresses come back in the order the
// conversation introduced them and a set built from them is stable between
// requests.
func collectThreadAddresses(db *sql.DB, rootMsgID int64) ([]threadAddress, error) {
// The identifier this message claims, and its own. A message with neither is
// in no thread that can be reconstructed.
var inReplyTo, messageID sql.NullString
if err := db.QueryRow(
`SELECT in_reply_to, message_id FROM messages WHERE id = ?`, rootMsgID,
).Scan(&inReplyTo, &messageID); err != nil {
return nil, fmt.Errorf("load thread root: %w", err)
}
startIRT := strings.TrimSpace(inReplyTo.String)
startMID := strings.TrimSpace(messageID.String)
if startIRT == "" && startMID == "" {
return nil, nil
}
// Climb to the root, then descend from it. Both halves carry the `<> ''`
// clauses that select the partial indexes and that stop the empty string from
// joining every unidentified message to every other; see migration014 and
// TestThreadLinksEmptyIdentifiersDoNotJoin.
//
// UNION, never UNION ALL: it collapses an identifier already seen, which is
// what makes the walk terminate on the ordinary case of a duplicated
// Message-ID. The depth cap handles a genuine cycle.
//
// Two things in the final SELECT look like mistakes and are not.
//
// CROSS JOIN fixes the order the two tables are visited in, which is the
// difference between this query costing a millisecond and costing two
// hundred. Written as an ordinary join, SQLite drives from messages and
// probes the materialised thread — a scan of all 29054 rows, because no index
// on messages orders them by "is in this thread". CROSS JOIN forbids
// reordering, so it drives from the handful of rows in the thread and probes
// messages through idx_messages_message_id. There is no cartesian product
// here despite the name; the ON clause is what it always was. Measured on the
// live archive, over a sample of 3000 replies: 210ms mean per walk before, 2.9ms
// after.
//
// The root message is fetched by a UNIONed second SELECT rather than by
// `OR m.id = ?` in the first. An OR against the primary key defeats the join
// order above and puts the scan back; as its own branch it is a primary-key
// lookup, and UNION merges the two.
const walk = threadWalkSQL
// The walk starts at whichever identifier this message has. Preferring the
// parent reaches the whole thread; falling back to its own identifier still
// reaches everything below it.
start := startIRT
if start == "" {
start = startMID
}
rows, err := db.Query(walk, start, threadMaxDepth, threadMaxDepth, rootMsgID)
if err != nil {
return nil, fmt.Errorf("walk thread: %w", err)
}
defer rows.Close()
var out []threadAddress
seen := make(map[string]bool)
add := func(msgID int64, kind, address string) {
r, err := parseRecipient(address)
if err != nil {
// Unparseable addresses are dropped rather than carried. These values
// came from headers and this is the path by which they would reach an
// envelope; see parseRecipient.
return
}
if isMyAddress(r.Address()) || seen[r.Address()] {
return
}
seen[r.Address()] = true
out = append(out, threadAddress{Rcpt: r, FromMsg: msgID, Kind: kind})
}
for rows.Next() {
var (
id int64
fromAddr string
toRaw []byte
ccRaw []byte
unixDate int64
)
if err := rows.Scan(&id, &fromAddr, &toRaw, &ccRaw, &unixDate); err != nil {
return nil, fmt.Errorf("scan thread message: %w", err)
}
// The sender first: whoever wrote a message in the thread belongs in To,
// since they are a party to it rather than somebody copied.
if _, address := parseFromAddr(fromAddr); address != "" {
add(id, rcptTo, address)
}
for _, a := range parseAddrList(string(toRaw)) {
add(id, rcptTo, a.Address)
}
for _, a := range parseAddrList(string(ccRaw)) {
add(id, rcptCc, a.Address)
}
}
return out, rows.Err()
}
// resolveThreadNames resolves the correspondent of every message in a set of
// links, keyed by message id.
//
// The names are resolved here, by the caller that already holds the petnames,
// rather than inside loadThreadLinks. The database pool is capped at one
// connection, so a petname lookup issued while the thread query's rows are still
// open waits for a connection only closing those rows can release — the handler
// deadlocks outright rather than merely running slowly. The same rule is why
// handleContactDetail loads petnames before opening its cursor.
func resolveThreadNames(petnames map[string]string, links threadLinks) map[int64]mailtext.Name {
out := make(map[int64]mailtext.Name)
if links.Parent != nil {
out[links.Parent.ID] = resolveDisplay(petnames, links.Parent.FromAddr)
}
for _, r := range links.Replies {
out[r.ID] = resolveDisplay(petnames, r.FromAddr)
}
return out
}
// loadThreadLinks reads what one message can say about its thread.
//
// Two queries and no walk. Both are single-hop, which is what keeps a message
// page cheap and is also what keeps it honest: a forged In-Reply-To can make
// this page mention a message that is already in the mirror, and can do nothing
// else. It cannot introduce an address, a name, or a message from elsewhere.
func loadThreadLinks(db *sql.DB, msgID int64) (threadLinks, error) {
var out threadLinks
// What this message claims about its parent, and its own identifier so the
// replies query knows what to look for.
var inReplyTo, messageID sql.NullString
err := db.QueryRow(
`SELECT in_reply_to, message_id FROM messages WHERE id = ?`, msgID,
).Scan(&inReplyTo, &messageID)
if err != nil {
return out, fmt.Errorf("load thread headers: %w", err)
}
parentID := strings.TrimSpace(inReplyTo.String)
selfID := strings.TrimSpace(messageID.String)
// Upwards. The `<> ''` is what lets the partial index answer this; see the
// file comment.
if parentID != "" {
out.ParentID = parentID
// LIMIT 1 with a deliberate ordering rather than an arbitrary row: a
// Message-ID may name several messages here, and picking whichever the
// engine happened to reach first would make this page's "in reply to"
// link change between requests. Oldest wins, because the original is
// what a reply was written against and a later copy is the duplicate.
rows, qErr := db.Query(
`SELECT id, subject, mailbox, from_addr, date
FROM messages
WHERE message_id = ? AND message_id <> ''
AND id <> ?
ORDER BY date ASC, id ASC
LIMIT 1`, parentID, msgID)
if qErr != nil {
return out, fmt.Errorf("query thread parent: %w", qErr)
}
refs, sErr := scanThreadRefs(rows)
if sErr != nil {
return out, sErr
}
if len(refs) > 0 {
out.Parent = &refs[0]
} else {
// Claimed and absent. Either older than the backfill window, in a
// mailbox that is not watched, or never existed.
out.ParentMissing = true
}
}
// Downwards. Skipped entirely when this message has no Message-ID, because
// then in_reply_to = '' would match every one of the 9798 messages that
// claim no parent and read them all as replies to this one.
//
// This guard and the `<> ''` in the SQL below are each sufficient on their
// own, and both are kept deliberately. Removing either leaves the behaviour
// correct, which is exactly what makes the pair worth a note: the next
// person to read this will see one of them as dead weight. Remove both — as
// a mutation test here does — and three unrelated messages with no
// identifiers become replies to one another. The SQL clause additionally
// earns its place by selecting the partial index; see migration014.
if selfID != "" {
rows, qErr := db.Query(
`SELECT id, subject, mailbox, from_addr, date
FROM messages
WHERE in_reply_to = ? AND in_reply_to <> ''
AND id <> ?
ORDER BY date ASC, id ASC`, selfID, msgID)
if qErr != nil {
return out, fmt.Errorf("query thread replies: %w", qErr)
}
replies, sErr := scanThreadRefs(rows)
if sErr != nil {
return out, sErr
}
out.Replies = replies
}
return out, nil
}
|