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
|
package main
import (
"bytes"
"fmt"
"io"
"log"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
gomessage "github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
gosmtp "github.com/emersion/go-smtp"
)
// ============================================================================
// SMTP credentials
// ============================================================================
type smtpCreds struct {
host string
port int
user string
pass string
}
// ============================================================================
// Message identity
// ============================================================================
// setMessageID stamps a fresh Message-ID on an outgoing message, using the
// domain of the From address as the right-hand side.
//
// Every message mailweb sends needs one, and until this existed none of them
// had one: go-message writes no Message-ID unless asked, so every unsubscribe
// request and every spam report went out unidentified. That is not merely
// untidy. A Message-ID is what In-Reply-To and References point at, so mail
// sent without one cannot be replied to in a way any client can thread —
// including mailweb itself, which stores the field and would have nothing to
// join a reply back to. It is also what a bounce names when reporting a
// message undeliverable, and what a receiving spam filter uses to recognise a
// retransmission rather than a duplicate.
//
// The domain comes from the From address rather than from os.Hostname, which
// is what GenerateMessageID would use. A local hostname is neither unique nor
// a FQDN — this machine is "rolery", and the one message hand-sent from it
// carries <…@rolery>, which no resolver can place and which tells every
// recipient the name of the sending workstation. The From domain is by
// construction a real domain that the account demonstrably sends from.
//
// A From address with no domain leaves the message without a Message-ID rather
// than inventing one: a fabricated right-hand side is worse than a missing
// header, since it claims an origin that does not exist.
func setMessageID(h *mail.Header, from string) {
_, domain, ok := strings.Cut(from, "@")
if !ok || domain == "" {
log.Printf("smtp: no domain in From %q; sending without a Message-ID", from)
return
}
if err := h.GenerateMessageIDWithHostname(domain); err != nil {
// Only the CSPRNG can fail here. The mail is still worth sending, so
// this is reported rather than fatal.
log.Printf("smtp: could not generate Message-ID: %v", err)
}
}
// ============================================================================
// Build RFC 5322 message
// ============================================================================
// buildMessage constructs a minimal RFC 5322 message and returns its raw bytes.
// contentType should be "text/plain" or "text/html"; defaults to "text/plain" if empty.
// unsubscribeFor, if non-empty, is written as X-Mailweb-Unsubscribe: <addr> so the
// sent mail can be matched back to the contact's conversation.
func buildMessage(from, to, subject, body, contentType, unsubscribeFor string) ([]byte, error) {
var buf bytes.Buffer
if contentType == "" {
contentType = "text/plain"
}
var h mail.Header
h.SetDate(time.Now())
setMessageID(&h, from)
h.SetAddressList("From", []*mail.Address{{Address: from}})
h.SetAddressList("To", []*mail.Address{{Address: to}})
h.Set("Subject", subject)
h.Set("Content-Type", contentType+"; charset=utf-8")
if unsubscribeFor != "" {
h.Set("X-Mailweb-Unsubscribe", unsubscribeFor)
}
wc, err := mail.CreateSingleInlineWriter(&buf, h)
if err != nil {
return nil, fmt.Errorf("create writer: %w", err)
}
if _, err := wc.Write([]byte(body)); err != nil {
return nil, fmt.Errorf("write body: %w", err)
}
if err := wc.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err)
}
return buf.Bytes(), nil
}
// ============================================================================
// Reply messages
// ============================================================================
// draftHeader builds the header block of a message being sent from a draft.
//
// In-Reply-To and References are what make a reply a reply: without them it is
// a new message that happens to start with "Re:", and every threading client
// shows it as one. Both come off the draft, where they were copied from the
// parent when it was created — so a parent since expunged does not cost the
// threading. A draft that answers nothing carries neither, and the two headers
// are simply absent rather than empty.
func draftHeader(d *draft, from string, set recipientSet) mail.Header {
var h mail.Header
h.SetDate(time.Now())
setMessageID(&h, from)
h.SetAddressList("From", []*mail.Address{{Address: from}})
h.SetAddressList("To", recipientAddressList(set.To))
if len(set.Cc) > 0 {
h.SetAddressList("Cc", recipientAddressList(set.Cc))
}
h.Set("Subject", d.Subject)
if d.InReplyTo != "" {
h.Set("In-Reply-To", d.InReplyTo)
}
if d.References != "" {
h.Set("References", d.References)
}
return h
}
// recipientAddressList converts recipients into the address form go-message
// wants. Recipients carry no display name: what mailweb knows a correspondent
// as is either their own claim, which is theirs to make and not mailweb's to
// repeat, or a petname, which must never leave this machine.
func recipientAddressList(rs []Recipient) []*mail.Address {
out := make([]*mail.Address, 0, len(rs))
for _, r := range rs {
out = append(out, &mail.Address{Address: r.Address()})
}
return out
}
// writePlainBody writes a single-part text/plain message.
//
// It takes an io.Writer rather than a buffer because it is used twice: once as
// the whole message, and once as a part inside a multipart/mixed when the draft
// carries attachments. The bytes it writes are identical either way, which is
// what keeps "what will be sent" the same sentence in both cases.
func writePlainBody(w io.Writer, h mail.Header, body string) error {
h.Set("Content-Type", "text/plain; charset=utf-8")
wc, err := mail.CreateSingleInlineWriter(w, h)
if err != nil {
return fmt.Errorf("create writer: %w", err)
}
if _, err := wc.Write([]byte(body)); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := wc.Close(); err != nil {
return fmt.Errorf("close writer: %w", err)
}
return nil
}
// writeAlternativeBody writes a multipart/alternative message carrying the same
// content as text and as HTML.
//
// The plain part is written first, which is required rather than stylistic:
// RFC 2046 orders the parts of an alternative from least to most faithful, and
// a client shows the last one it can render. Reversing them shows plain text to
// everybody.
//
// The alternative is the top level of the message rather than being wrapped in
// a multipart/mixed. go-message's mail.CreateWriter always writes mixed, which
// is what a message with attachments needs and is a layer of nesting that this
// one does not: mixed says "several things are enclosed" where there is exactly
// one thing in two renderings. Every client copes with the extra layer, but the
// structure would be describing a message other than the one being sent. So the
// content type is set here and the generic entity writer used directly.
func writeAlternativeBody(w io.Writer, h mail.Header, plain, htmlBody string) error {
h.Set("Content-Type", "multipart/alternative")
mw, err := gomessage.CreateWriter(w, h.Header)
if err != nil {
return fmt.Errorf("create writer: %w", err)
}
if err := writeAlternativeParts(mw, plain, htmlBody); err != nil {
return err
}
if err := mw.Close(); err != nil {
return fmt.Errorf("close message: %w", err)
}
return nil
}
// writeTextPart writes one text part of a multipart entity.
func writeTextPart(mw *gomessage.Writer, contentType, body string) error {
var ph gomessage.Header
ph.Set("Content-Type", contentType+"; charset=utf-8")
ph.Set("Content-Transfer-Encoding", "quoted-printable")
pw, err := mw.CreatePart(ph)
if err != nil {
return fmt.Errorf("create %s part: %w", contentType, err)
}
if _, err := pw.Write([]byte(body)); err != nil {
return fmt.Errorf("write %s part: %w", contentType, err)
}
return pw.Close()
}
// writeAlternativeParts fills a multipart/alternative with the two renderings,
// plain first.
//
// Shared by the two places an alternative is written — as the whole message,
// and as the body part of a message that also carries attachments — so the
// ordering rule is stated once. RFC 2046 orders the parts least to most
// faithful and a client shows the last one it can render; reversing them shows
// plain text to everybody.
func writeAlternativeParts(mw *gomessage.Writer, plain, htmlBody string) error {
if err := writeTextPart(mw, "text/plain", plain); err != nil {
return err
}
return writeTextPart(mw, "text/html", htmlBody)
}
// writePlainInto writes a text/plain body as a part of an enclosing multipart
// entity, rather than as the whole message.
func writePlainInto(mw *gomessage.Writer, body string) error {
return writeTextPart(mw, "text/plain", body)
}
// writeAlternativeInto writes a multipart/alternative body as a part of an
// enclosing multipart entity.
//
// The part's own header is what makes it an alternative: CreatePart reads the
// Content-Type it is given and hands back a writer that is itself multipart, so
// the two renderings become children of this part rather than of the message.
// Writing a fresh entity into an untyped part instead — the obvious shortcut —
// produces a text/plain part whose body happens to begin with a header block,
// which is what this looked like before it was tested.
func writeAlternativeInto(mw *gomessage.Writer, plain, htmlBody string) error {
var ph gomessage.Header
ph.Set("Content-Type", "multipart/alternative")
aw, err := mw.CreatePart(ph)
if err != nil {
return fmt.Errorf("create alternative part: %w", err)
}
if err := writeAlternativeParts(aw, plain, htmlBody); err != nil {
return err
}
if err := aw.Close(); err != nil {
return fmt.Errorf("close alternative part: %w", err)
}
return nil
}
// writeMixedBody writes a message whose body is enclosed alongside attachments.
//
// This is where mixed is correct and where writeAlternativeBody's argument
// against it does not apply. There, mixed would have claimed several things
// were enclosed when there was one thing in two renderings; here several things
// really are enclosed, and mixed is the structure that says so. The two
// functions are opposite halves of one rule rather than a preference reversed.
//
// The body is written by a callback rather than passed as bytes, so the choice
// between plain and alternative is made in exactly one place — buildDraftMessage
// — and is the same choice whether or not anything is attached. A message with
// attachments must not quietly become HTML because it was assembled by
// different code.
//
// The callback is handed the multipart writer rather than an io.Writer, because
// a part's type lives in the part's own header: an alternative nested here is a
// part whose Content-Type says so, not a whole entity written into an untyped
// one. See writeAlternativeInto.
func writeMixedBody(
w io.Writer,
h mail.Header,
writeBody func(*gomessage.Writer) error,
attachments []draftAsset,
) error {
h.Set("Content-Type", "multipart/mixed")
mw, err := gomessage.CreateWriter(w, h.Header)
if err != nil {
return fmt.Errorf("create writer: %w", err)
}
// The body goes first: From, To, Subject and the threading headers stay on
// the message, since they address the whole of it and not one of its parts.
if err := writeBody(mw); err != nil {
return err
}
for _, a := range attachments {
var ah gomessage.Header
// The type is the one derived from the bytes at upload; see
// detectAssetType. What the uploading client called it never reaches
// here.
ah.Set("Content-Type", a.MimeType)
ah.Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", sanitiseFilename(a.Filename)))
// base64 because an attachment is arbitrary bytes: quoted-printable
// would inflate a JPEG by roughly three times, and 8bit is not
// guaranteed by every hop.
ah.Set("Content-Transfer-Encoding", "base64")
aw, err := mw.CreatePart(ah)
if err != nil {
return fmt.Errorf("create attachment %q: %w", a.Filename, err)
}
if _, err := aw.Write(a.Bytes); err != nil {
return fmt.Errorf("write attachment %q: %w", a.Filename, err)
}
if err := aw.Close(); err != nil {
return fmt.Errorf("close attachment %q: %w", a.Filename, err)
}
}
if err := mw.Close(); err != nil {
return fmt.Errorf("close message: %w", err)
}
return nil
}
// sendRaw hands already-built message bytes to the SMTP server.
//
// Separate from sendMail because a reply is built before it is sent — the draft
// page shows what will go out — so the bytes exist before anything is handed
// over, and building them again to send would risk sending something other than
// what was shown.
func sendRaw(creds smtpCreds, from string, to []Recipient, raw []byte) error {
addr := fmt.Sprintf("%s:%d", creds.host, creds.port)
auth := sasl.NewPlainClient("", creds.user, creds.pass)
if err := gosmtp.SendMailTLS(addr, auth, from, recipientAddresses(to), bytes.NewReader(raw)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
log.Printf("smtp: sent message from %s to %v", from, recipientAddresses(to))
return nil
}
// ============================================================================
// Send via SMTP
// ============================================================================
// sendMail builds and sends an email via SMTP implicit TLS (port 465).
// contentType is passed through to buildMessage ("text/plain" or "text/html").
// unsubscribeFor, if non-empty, sets the X-Mailweb-Unsubscribe header.
// Returns the raw message bytes so the caller can append to Sent.
func sendMail(creds smtpCreds, from, to, subject, body, contentType, unsubscribeFor string) ([]byte, error) {
raw, err := buildMessage(from, to, subject, body, contentType, unsubscribeFor)
if err != nil {
return nil, fmt.Errorf("build message: %w", err)
}
addr := fmt.Sprintf("%s:%d", creds.host, creds.port)
log.Printf("smtp: connecting to %s ...", addr)
auth := sasl.NewPlainClient("", creds.user, creds.pass)
if err := gosmtp.SendMailTLS(addr, auth, from, []string{to}, bytes.NewReader(raw)); err != nil {
return nil, fmt.Errorf("smtp send: %w", err)
}
log.Printf("smtp: sent message from %s to %s", from, to)
return raw, nil
}
// ============================================================================
// Append to Sent mailbox via IMAP
// ============================================================================
// appendToSent appends raw message bytes to the IMAP Sent mailbox.
// Opens a fresh connection so it does not disturb the pool's SELECT state.
func appendToSent(creds imapCreds, raw []byte) error {
c, err := connectIMAP(creds)
if err != nil {
return fmt.Errorf("imap connect: %w", err)
}
defer c.Logout()
sentName, err := findSentMailbox(c)
if err != nil {
return err
}
flags := []string{imap.SeenFlag}
if err := c.Append(sentName, flags, time.Now(), strings.NewReader(string(raw))); err != nil {
return fmt.Errorf("imap append to %s: %w", sentName, err)
}
log.Printf("smtp: appended message to IMAP %s", sentName)
return nil
}
// findSentMailbox lists all mailboxes and returns the one with the \Sent
// special-use attribute, falling back to names "Sent" or "Sent Messages".
func findSentMailbox(c *client.Client) (string, error) {
mboxes := make(chan *imap.MailboxInfo, 20)
done := make(chan error, 1)
go func() { done <- c.List("", "*", mboxes) }()
var sentByAttr, sentByName string
for m := range mboxes {
for _, attr := range m.Attributes {
if strings.EqualFold(attr, `\Sent`) {
sentByAttr = m.Name
}
}
lower := strings.ToLower(m.Name)
if lower == "sent" || lower == "sent messages" {
sentByName = m.Name
}
}
if err := <-done; err != nil {
return "", fmt.Errorf("list mailboxes: %w", err)
}
if sentByAttr != "" {
return sentByAttr, nil
}
if sentByName != "" {
return sentByName, nil
}
// Fall back to "Sent" and let APPEND fail with a clear error.
return "Sent", nil
}
// ============================================================================
// Synthetic mailweb action messages
// ============================================================================
// buildActionMessage constructs a synthetic RFC 5322 message recording a
// mailweb action (e.g. URL unsubscribe intent). The message is addressed
// from/to the user's own address and carries X-Mailweb-Unsubscribe so it
// appears in the contact's conversation thread.
//
// extraHeaders is an optional map of additional header name → value pairs
// (e.g. "X-Mailweb-Unsubscribe-URL" → "https://...").
func buildActionMessage(from, contact, subject, body string, extraHeaders map[string]string) ([]byte, error) {
var buf bytes.Buffer
var h mail.Header
h.SetDate(time.Now())
setMessageID(&h, from)
h.SetAddressList("From", []*mail.Address{{Address: from}})
h.SetAddressList("To", []*mail.Address{{Address: from}})
h.Set("Subject", subject)
h.Set("Content-Type", "text/plain; charset=utf-8")
h.Set("X-Mailweb-Unsubscribe", contact)
for k, v := range extraHeaders {
h.Set(k, v)
}
wc, err := mail.CreateSingleInlineWriter(&buf, h)
if err != nil {
return nil, fmt.Errorf("create writer: %w", err)
}
if _, err := wc.Write([]byte(body)); err != nil {
return nil, fmt.Errorf("write body: %w", err)
}
if err := wc.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err)
}
return buf.Bytes(), nil
}
// appendToMailweb appends raw message bytes to the mailweb synthetic folder.
// If the folder does not exist it is created first.
func appendToMailweb(creds imapCreds, folder string, raw []byte) error {
c, err := connectIMAP(creds)
if err != nil {
return fmt.Errorf("imap connect: %w", err)
}
defer c.Logout()
// Create folder if it doesn't exist (error silently ignored).
if err := c.Create(folder); err != nil {
log.Printf("imap: CREATE %q: %v (may already exist)", folder, err)
}
flags := []string{imap.SeenFlag}
if err := c.Append(folder, flags, time.Now(), strings.NewReader(string(raw))); err != nil {
return fmt.Errorf("imap append to %s: %w", folder, err)
}
log.Printf("mailweb: appended action message to IMAP %s", folder)
return nil
}
// ============================================================================
// Spam report: multipart/mixed with message/rfc822 attachments
// ============================================================================
// buildSpamReport constructs a multipart/mixed email containing:
// - a text/plain cover letter (coverNote)
// - one message/rfc822 part per raw message in attachments
func buildSpamReport(from, to, subject, coverNote string, attachments [][]byte) ([]byte, error) {
var buf bytes.Buffer
var h mail.Header
h.SetDate(time.Now())
setMessageID(&h, from)
h.SetAddressList("From", []*mail.Address{{Address: from}})
h.SetAddressList("To", []*mail.Address{{Address: to}})
h.Set("Subject", subject)
mw, err := mail.CreateWriter(&buf, h)
if err != nil {
return nil, fmt.Errorf("create writer: %w", err)
}
// Part 1: plain-text cover letter.
var ih mail.InlineHeader
ih.Set("Content-Type", "text/plain; charset=utf-8")
tw, err := mw.CreateSingleInline(ih)
if err != nil {
return nil, fmt.Errorf("create inline part: %w", err)
}
if _, err := tw.Write([]byte(coverNote)); err != nil {
return nil, fmt.Errorf("write cover note: %w", err)
}
if err := tw.Close(); err != nil {
return nil, fmt.Errorf("close cover note: %w", err)
}
// Parts 2+: one .eml attachment per message. message/rfc822 is what an
// attached mail is, and is what the recipient's tooling reads to recover
// the original headers.
for i, raw := range attachments {
var ah gomessage.Header
ah.Set("Content-Type", "message/rfc822")
ah.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="spam-%d.eml"`, i+1))
ah.Set("Content-Transfer-Encoding", "base64")
aw, err := mw.CreateAttachment(mail.AttachmentHeader{Header: ah})
if err != nil {
return nil, fmt.Errorf("create attachment %d: %w", i+1, err)
}
if _, err := aw.Write(raw); err != nil {
return nil, fmt.Errorf("write attachment %d: %w", i+1, err)
}
if err := aw.Close(); err != nil {
return nil, fmt.Errorf("close attachment %d: %w", i+1, err)
}
}
if err := mw.Close(); err != nil {
return nil, fmt.Errorf("close message: %w", err)
}
return buf.Bytes(), nil
}
|