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
|
package main
// Deriving a reply from a message.
//
// Everything a reply needs is read out of the message being answered, once,
// when the draft is created: who it could go to, what it is about, and where it
// belongs in the thread. Nothing here sends anything, and nothing here is
// consulted again at send time — the draft carries its own copy, so a message
// expunged between composing and sending costs the quoted context and not the
// recipients.
//
// The recipients are the part worth being careful about. A reply is the first
// thing in mailweb that writes to a stranger, and the addresses come from
// headers the sender controls: Reply-To names where the sender would like
// answers to go, which is not always where they came from, and List-Post names
// somewhere answers reach everybody. So the sets are derived separately, named,
// and shown in full before anything is sent — mailweb picks no default and the
// interface offers no button that does not say who it writes to.
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)
// parentMessage is what a reply is derived from: the stored envelope of the
// message being answered, plus the headers that decide where a reply goes.
type parentMessage struct {
ID int64
Mailbox string
// UID and UIDValidity locate the message on the server, and are needed to
// fetch a body that has not been cached yet: a message nobody has opened
// has no display part, and quoting it means fetching it exactly as opening
// it would.
UID uint32
UIDValidity uint32
Subject string
FromAddr string
ToAddrs []addr
CcAddrs []addr
Date time.Time
MessageID string
InReplyTo string
Refs string
// ReplyTo is the Reply-To header, "" when absent. It is where the sender
// asked for answers, which is frequently not where the mail came from:
// most of the mail in this archive that carries one points somewhere else.
ReplyTo string
// ListPost is the List-Post header, "" when absent.
ListPost string
// Body and BodyMime are the cached display part, used to seed the quote.
Body string
BodyMime string
}
// loadParent reads the message a reply is being written to.
func loadParent(db *sql.DB, msgID int64) (*parentMessage, error) {
var (
p parentMessage
toRaw []byte
ccRaw []byte
unixDate int64
body []byte
mime sql.NullString
messageID sql.NullString
inReplyTo sql.NullString
references_ sql.NullString
)
err := db.QueryRow(
`SELECT id, mailbox, uid, uidvalidity, subject, from_addr, to_addrs,
cc_addrs, date, message_id, in_reply_to, references_,
display_part, display_part_mime
FROM messages WHERE id = ?`, msgID,
).Scan(&p.ID, &p.Mailbox, &p.UID, &p.UIDValidity, &p.Subject, &p.FromAddr,
&toRaw, &ccRaw, &unixDate,
&messageID, &inReplyTo, &references_, &body, &mime)
if err != nil {
return nil, err
}
p.Date = time.Unix(unixDate, 0)
p.MessageID = strings.TrimSpace(messageID.String)
p.InReplyTo = strings.TrimSpace(inReplyTo.String)
p.Refs = strings.TrimSpace(references_.String)
p.Body = string(body)
p.BodyMime = mime.String
if len(toRaw) > 0 {
json.Unmarshal(toRaw, &p.ToAddrs)
}
if len(ccRaw) > 0 {
json.Unmarshal(ccRaw, &p.CcAddrs)
}
// Reply-To and List-Post are not in the IMAP envelope, so they come from
// the normalised header table rather than the message row.
rows, err := db.Query(
`SELECT name, value FROM message_headers
WHERE message_id = ? AND name IN ('reply-to', 'list-post')`, msgID)
if err != nil {
return nil, fmt.Errorf("query reply headers: %w", err)
}
defer rows.Close()
for rows.Next() {
var name, value string
if err := rows.Scan(&name, &value); err != nil {
return nil, fmt.Errorf("scan reply header: %w", err)
}
switch name {
case "reply-to":
if p.ReplyTo == "" {
p.ReplyTo = value
}
case "list-post":
if p.ListPost == "" {
p.ListPost = value
}
}
}
return &p, rows.Err()
}
// ============================================================================
// Recipients
// ============================================================================
// replySubject prefixes a subject with "Re: " unless it already carries one.
//
// The test is deliberately loose about which language did the prefixing: a
// thread that has been through a German client carries "AW:", a French one
// "RE:", and stacking "Re: AW: Re:" is how a subject line becomes unreadable
// after four exchanges. Only one prefix is added, and an existing one of any of
// these forms is left exactly as it is rather than normalised, since rewriting
// somebody else's subject is not this program's business.
func replySubject(subject string) string {
trimmed := strings.TrimSpace(subject)
if trimmed == "" {
return "Re:"
}
lower := strings.ToLower(trimmed)
for _, prefix := range []string{"re:", "aw:", "antw:", "sv:", "vs:", "ref:"} {
if strings.HasPrefix(lower, prefix) {
return trimmed
}
}
return "Re: " + trimmed
}
// replyReferences builds the References header of a reply.
//
// RFC 5322 says a reply's References is the parent's References followed by the
// parent's Message-ID, which is what lets a client assemble a thread without
// having seen every message in it. A parent with no Message-ID contributes
// nothing rather than an empty entry — mail sent by mailweb before it set one
// is exactly that case, which is why that was fixed first.
func replyReferences(p *parentMessage) string {
var parts []string
if p.Refs != "" {
parts = append(parts, strings.Fields(p.Refs)...)
}
if p.MessageID != "" {
parts = append(parts, p.MessageID)
}
return strings.Join(parts, " ")
}
// parseAddressHeader pulls the addresses out of a raw header value such as
// Reply-To or List-Post.
//
// Anything that does not parse as an address is dropped rather than passed
// along: these values are written by the sender, and this is the path by which
// they would reach an envelope. A List-Post of "NO" — which RFC 2369 defines
// for lists that refuse postings — therefore yields nothing and no list button
// is offered, which is the correct reading of it.
func parseAddressHeader(value string) []Recipient {
var out []Recipient
seen := map[string]bool{}
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Both headers may wrap addresses in <>, and List-Post uses a mailto:
// URI: "<mailto:list@example.org>".
if i := strings.Index(part, "<"); i >= 0 {
if j := strings.Index(part[i:], ">"); j > 0 {
part = part[i+1 : i+j]
}
}
part = strings.TrimSpace(part)
if idx := strings.Index(strings.ToLower(part), "mailto:"); idx == 0 {
part = part[len("mailto:"):]
// A mailto: may carry parameters, which are not part of the
// address.
if q := strings.Index(part, "?"); q >= 0 {
part = part[:q]
}
}
r, err := parseRecipient(part)
if err != nil {
continue
}
if seen[r.Address()] {
continue
}
seen[r.Address()] = true
out = append(out, r)
}
return out
}
// deriveRecipientSets works out who a reply to this message could go to.
//
// Three named sets, each a different question, and mailweb answers none of them
// on its own behalf:
//
// - sender: back to whoever wrote it. Reply-To when the sender named one,
// since that is what the header is for, and From otherwise.
// - all: the sender plus everyone the message was addressed to, minus this
// account. That subtraction is why myAddresses exists — an alias not
// recognised as this account ends up in the To: header of the account's own
// reply, where everyone else on the thread will see it and write to it.
// - list: the address in List-Post, when the message came through a mailing
// list that accepts postings.
//
// A set that names nobody is omitted rather than being offered empty, so a
// message with no List-Post yields no list button at all.
func deriveRecipientSets(p *parentMessage) map[string]recipientSet {
sets := make(map[string]recipientSet)
// Who the reply goes back to. Reply-To is the sender's own statement about
// where answers belong, so it wins over From when present and parseable.
var sender []Recipient
if p.ReplyTo != "" {
sender = parseAddressHeader(p.ReplyTo)
}
if len(sender) == 0 {
_, fromAddress := parseFromAddr(p.FromAddr)
if r, err := parseRecipient(fromAddress); err == nil {
sender = []Recipient{r}
}
}
// Replying to mail this account sent goes to the people it was sent to,
// not to itself. Without this, answering one's own message in a thread
// addresses it back to the mailbox it is already in.
if len(sender) == 1 && isMyAddress(sender[0].Address()) {
sender = nil
for _, a := range p.ToAddrs {
if r, err := parseRecipient(a.Address); err == nil && !isMyAddress(r.Address()) {
sender = append(sender, r)
}
}
}
if len(sender) > 0 {
sets[setSender] = recipientSet{Name: setSender, To: sender}
}
// Everyone on the thread: the sender in To, the rest in Cc, with this
// account and any duplicate removed.
seen := map[string]bool{}
var to, cc []Recipient
for _, r := range sender {
if seen[r.Address()] {
continue
}
seen[r.Address()] = true
to = append(to, r)
}
addCc := func(list []addr) {
for _, a := range list {
r, err := parseRecipient(a.Address)
if err != nil || seen[r.Address()] || isMyAddress(r.Address()) {
continue
}
seen[r.Address()] = true
cc = append(cc, r)
}
}
addCc(p.ToAddrs)
addCc(p.CcAddrs)
// Offered only when it reaches somebody the plain reply would not, since
// otherwise it is the same act under a name that suggests it is wider.
if len(cc) > 0 {
sets[setAll] = recipientSet{Name: setAll, To: to, Cc: cc}
}
if p.ListPost != "" {
if list := parseAddressHeader(p.ListPost); len(list) > 0 {
sets[setList] = recipientSet{Name: setList, To: list}
}
}
return sets
}
// ============================================================================
// Composing the draft
// ============================================================================
// quoteAttribution is the line introducing a quoted passage, naming who wrote
// it and when, as it will appear in the sent mail.
//
// It deliberately uses only what the sender wrote about themselves — the
// display name from their From: header, or the bare address — and never a
// petname, even where one exists.
//
// A petname is local by construction: it is the one name in mailweb that never
// travelled, which is exactly what makes it worth trusting. This string is body
// text and goes to every recipient of the reply, so putting a petname in it
// would publish the account owner's private name for someone to that person and
// to everyone else on the thread, including a mailing list. It would also be a
// name the recipients have no way to interpret.
//
// The *display* of a quote block does resolve a petname — see QuoteFrom — so
// the reader sees the name they chose while the recipient sees the name their
// correspondent chose. That the two differ is the point rather than an
// inconsistency.
func quoteAttribution(claimed, address string, date time.Time) string {
who := address
if claimed != "" {
who = claimed + " <" + address + ">"
}
return fmt.Sprintf("On %s, %s wrote:", date.Format("2006-01-02 15:04"), who)
}
// newReplyDraft builds — but does not store — the draft replying to a message.
//
// The draft opens with an empty text block above the quote, because that is
// where the reply gets written and a cursor should land in it. The quote
// follows, seeded with the parent's body converted to text: a reply quoting
// somebody's HTML back at them republishes their markup over the account
// owner's signature, and the text conversion is what mailweb already trusts
// itself to read.
func newReplyDraft(p *parentMessage) *draft {
claimed, address := parseFromAddr(p.FromAddr)
blocks := []draftBlock{
{Kind: blockText, Content: ""},
}
if quoted := strings.TrimSpace(mailtext.RenderBody(p.BodyMime, p.Body)); quoted != "" {
blocks = append(blocks, draftBlock{
Kind: blockQuote,
Content: quoted,
Meta: blockMeta{
Attribution: quoteAttribution(claimed, address, p.Date),
QuoteFrom: address,
QuoteName: claimed,
},
})
}
return &draft{
ParentID: p.ID,
Subject: replySubject(p.Subject),
InReplyTo: p.MessageID,
References: replyReferences(p),
Blocks: blocks,
Recipients: deriveRecipientSets(p),
}
}
|