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
|
package main
import (
"bytes"
"database/sql"
"fmt"
"html/template"
"io"
"log"
"net/url"
"regexp"
"strings"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-message"
)
// msgRef identifies a stored message for on-demand body fetching.
type msgRef struct {
id int64
mbox Mailbox
uid uint32
}
// displayPart describes the MIME part chosen for display.
type displayPart struct {
path []int // IMAP section path, e.g. [2 1]
mimeType string // full type, e.g. "text/html"
encoding string // Content-Transfer-Encoding, e.g. "quoted-printable"
charset string // charset from Content-Type params, e.g. "iso-8859-1"
rfc822Text bool // true if fallback to RFC822.TEXT
}
// findDisplayPart walks a BodyStructure and returns the best part to display.
//
// Selection algorithm (two passes):
//
// Pass 1: find text/html candidates
// Pass 2: find text/plain candidates
// Fallback: RFC822.TEXT
//
// Among candidates: prefer minimum semantic depth, then first occurrence.
// Semantic depth: number of ancestors that are NOT multipart/alternative.
// multipart/alternative is transparent — it just says "these are equivalent",
// so it doesn't count as a nesting level.
func findDisplayPart(bs *imap.BodyStructure) displayPart {
type candidate struct {
path []int
semanticDepth int
encoding string
charset string
}
var htmlCands, plainCands []candidate
var walk func(part *imap.BodyStructure, path []int, semDepth int)
walk = func(part *imap.BodyStructure, path []int, semDepth int) {
mt := strings.ToLower(part.MIMEType)
mst := strings.ToLower(part.MIMESubType)
if mt == "multipart" {
// multipart/alternative is transparent — don't increment semantic depth.
childDepth := semDepth
if mst != "alternative" {
childDepth++
}
for i, child := range part.Parts {
childPath := append(append([]int{}, path...), i+1) // 1-indexed
walk(child, childPath, childDepth)
}
return
}
if mt == "text" {
c := candidate{path: path, semanticDepth: semDepth, encoding: part.Encoding, charset: part.Params["charset"]}
switch mst {
case "html":
htmlCands = append(htmlCands, c)
case "plain":
plainCands = append(plainCands, c)
}
}
}
walk(bs, []int{}, 0)
best := func(cands []candidate, mimeType string) (displayPart, bool) {
if len(cands) == 0 {
return displayPart{}, false
}
minDepth := cands[0].semanticDepth
for _, c := range cands[1:] {
if c.semanticDepth < minDepth {
minDepth = c.semanticDepth
}
}
for _, c := range cands {
if c.semanticDepth == minDepth {
// Non-multipart message: the top-level part has an empty path.
// BODY[] would fetch headers+body; use RFC822.TEXT to get body only.
if len(c.path) == 0 {
return displayPart{rfc822Text: true, mimeType: mimeType, encoding: c.encoding, charset: c.charset}, true
}
return displayPart{path: c.path, mimeType: mimeType, encoding: c.encoding, charset: c.charset}, true
}
}
return displayPart{}, false
}
if p, ok := best(htmlCands, "text/html"); ok {
return p
}
if p, ok := best(plainCands, "text/plain"); ok {
return p
}
// RFC822.TEXT fallback — treat as plain text for rendering purposes.
return displayPart{rfc822Text: true, mimeType: "text/plain"}
}
// decodePartBytes decodes transfer-encoded part bytes and converts the charset
// to UTF-8. The encoding string comes from BodyStructure.Encoding (e.g.
// "quoted-printable") and charset from BodyStructure.Params["charset"] (e.g.
// "iso-8859-1"). Either may be empty.
// go-message/charset (imported as side-effect) registers all common charset
// decoders so message.New handles both steps in one pass.
func decodePartBytes(raw []byte, encoding, charset string) ([]byte, error) {
var hdr message.Header
if encoding != "" {
hdr.Set("Content-Transfer-Encoding", encoding)
}
if charset != "" {
hdr.Set("Content-Type", "text/plain; charset="+charset)
}
entity, err := message.New(hdr, bytes.NewReader(raw))
if err != nil && !message.IsUnknownEncoding(err) {
return nil, fmt.Errorf("decode %s/%s: %w", encoding, charset, err)
}
if err != nil {
// Unknown encoding or charset — return raw bytes as-is.
return raw, nil
}
decoded, err := io.ReadAll(entity.Body)
if err != nil {
return nil, fmt.Errorf("read decoded body: %w", err)
}
return decoded, nil
}
// pathToSectionName converts an IMAP path like [2 1] to a BodySectionName
// for use in a fetch request.
func pathToSectionName(path []int) (*imap.BodySectionName, error) {
parts := make([]string, len(path))
for i, n := range path {
parts[i] = fmt.Sprintf("%d", n)
}
section := strings.Join(parts, ".")
return imap.ParseBodySectionName(imap.FetchItem("BODY[" + section + "]"))
}
// expungedError describes a UID FETCH that returned nothing even after the
// connection was brought up to date.
//
// A FETCH against a UID that no longer exists is not an error in IMAP: the
// server answers OK with no untagged FETCH, since the UID range it was given
// simply matched nothing. But "the server has no such UID" is not the only way
// to get that answer, and the difference matters — see refreshSession.
//
// Once the session is known to be current, an empty answer does mean the
// message was expunged — moved by a server-side filter or deleted in another
// client — between the sync that recorded it and this attempt to read it.
// Because bodies are fetched lazily, this is the first moment mailweb could
// possibly find out.
//
// It is worth naming rather than reporting as a missing BODYSTRUCTURE, which
// describes the symptom and suggests a malformed message: the message is not
// malformed, it is not there.
func expungedError(mbox Mailbox, uid uint32) error {
return fmt.Errorf(
"message no longer on server: expunged from %s (uid %d) since it was synced",
mbox.Name, uid)
}
// refreshSession brings a pooled connection's view of its mailbox up to date,
// and reports whether the caller should retry.
//
// A connection's knowledge of a mailbox is bounded by its session — the stretch
// of interaction since it selected that mailbox — and a server is only obliged
// to announce new mail while it is processing a command (RFC 3501 §5.2). It
// may volunteer the news unasked (§5.3), but it need not, so a connection that
// has been sitting in the pool since before a message arrived can legitimately
// know nothing about it. Asking such a connection for that UID yields the same
// empty answer as asking about a message that was deleted.
//
// The two are indistinguishable in the response and opposite in meaning: one
// is a message that is fine and one is a message that is gone. Mailweb reads
// the mailbox over a pool of connections while a separate one syncs headers,
// so the mail most likely to be read — the message that just arrived, which is
// at the top of the listing — is exactly the mail an idle pooled connection is
// least likely to have heard of.
//
// NOOP settles it. It is a command, so anything the server has been holding
// back is delivered while it runs; RFC 3501 §6.1.2 names this as the way to
// poll an idle connection. A retry that then succeeds proves the message was
// there all along, and one that comes back empty a second time has ruled the
// possibility out.
//
// Errors are returned rather than swallowed: a NOOP that fails means the
// connection is unusable, which is worth saying plainly rather than reporting
// as a message that cannot be found.
func refreshSession(c *client.Client, mbox Mailbox, uid uint32, what string) error {
if err := c.Noop(); err != nil {
return fmt.Errorf("refresh session for %s uid %d: %w", mbox.Name, uid, err)
}
log.Printf("imap: %s uid=%d absent from connection session; refreshed and retrying",
what, uid)
return nil
}
// fetchBodyStructure fetches the BODYSTRUCTURE of one message.
//
// This is the first pass of every on-demand read: the tree it returns is what
// names the part to display, the part behind a cid: reference and the part an
// attachment lives at. An empty answer is retried once against a refreshed
// session before it is believed — see refreshSession for why an empty answer
// is not yet evidence of anything.
func fetchBodyStructure(c *client.Client, mbox Mailbox, uid uint32) (*imap.BodyStructure, error) {
seqset := new(imap.SeqSet)
seqset.AddNum(uid)
attempt := func() (*imap.BodyStructure, error) {
msgs := make(chan *imap.Message, 1)
done := make(chan error, 1)
go func() {
done <- c.UidFetch(seqset, []imap.FetchItem{imap.FetchUid, imap.FetchBodyStructure}, msgs)
}()
var bs *imap.BodyStructure
for msg := range msgs {
bs = msg.BodyStructure
}
if err := <-done; err != nil {
return nil, fmt.Errorf("bodystructure fetch: %w", err)
}
return bs, nil
}
bs, err := attempt()
if err != nil {
return nil, err
}
if bs != nil {
return bs, nil
}
if err := refreshSession(c, mbox, uid, "BODYSTRUCTURE"); err != nil {
return nil, err
}
bs, err = attempt()
if err != nil {
return nil, err
}
if bs == nil {
return nil, expungedError(mbox, uid)
}
return bs, nil
}
// fetchSingleBody fetches the display part of a message using a two-pass
// approach (BodyStructure then targeted section fetch), stores it in
// display_part + display_part_mime, and returns the bytes and mime type.
func fetchSingleBody(db *sql.DB, pool *globalPool, ref msgRef) ([]byte, string, error) {
tTotal := time.Now()
mailbox := ref.mbox.Name
c, err := pool.acquire(mailbox)
if err != nil {
return nil, "", err
}
defer func() {
pool.release(c, mailbox)
log.Printf("imap: fetchSingleBody uid=%d total=%s", ref.uid, time.Since(tTotal))
}()
seqset := new(imap.SeqSet)
seqset.AddNum(ref.uid)
// Pass 1: fetch BodyStructure to find which part to display.
t1 := time.Now()
bs, err := fetchBodyStructure(c, ref.mbox, ref.uid)
if err != nil {
return nil, "", err
}
log.Printf("imap: BODYSTRUCTURE uid=%d took=%s", ref.uid, time.Since(t1))
dp := findDisplayPart(bs)
// The same tree that names the display part also names everything else in
// the message, so record the attachments while it is in hand: this is the
// one moment they can be learned without an extra round-trip.
//
// Non-fatal by construction. Reading a message is the primary function and
// a note about what is attached to it is not; failing the read because the
// note could not be written would trade the thing that matters for the
// thing that does not.
if err := recordAttachments(db, ref.id, collectAttachments(bs, dp)); err != nil {
log.Printf("warn: record attachments for msg %d: %v", ref.id, err)
}
// Pass 2: fetch the chosen part.
var pass2Item imap.FetchItem
var sectionKey string
if dp.rfc822Text {
pass2Item = imap.FetchRFC822Text
sectionKey = string(imap.FetchRFC822Text)
} else {
sn, err := pathToSectionName(dp.path)
if err != nil {
return nil, "", fmt.Errorf("build section name: %w", err)
}
pass2Item = sn.FetchItem()
sectionKey = string(pass2Item)
}
msgs2 := make(chan *imap.Message, 1)
done2 := make(chan error, 1)
t2 := time.Now()
go func() { done2 <- c.UidFetch(seqset, []imap.FetchItem{imap.FetchUid, pass2Item}, msgs2) }()
var rawBody []byte
for msg := range msgs2 {
body, err := readMsgBody(msg)
if err != nil {
return nil, "", fmt.Errorf("read body: %w", err)
}
rawBody = body[sectionKey]
}
if err := <-done2; err != nil {
return nil, "", fmt.Errorf("body fetch: %w", err)
}
log.Printf("imap: BODY[%s] uid=%d took=%s", sectionKey, ref.uid, time.Since(t2))
if rawBody == nil {
return nil, "", fmt.Errorf("server returned no body for uid %d section %s", ref.uid, sectionKey)
}
// Decode transfer encoding and convert charset to UTF-8.
decoded, err := decodePartBytes(rawBody, dp.encoding, dp.charset)
if err != nil {
return nil, "", fmt.Errorf("decode transfer encoding: %w", err)
}
// Store for future requests — non-fatal if it fails.
if _, err := db.Exec(
`UPDATE messages SET display_part = ?, display_part_mime = ? WHERE id = ?`,
decoded, dp.mimeType, ref.id,
); err != nil {
log.Printf("warn: store display_part for msg %d: %v", ref.id, err)
}
return decoded, dp.mimeType, nil
}
// cidRe matches cid: URLs in HTML attribute values, e.g. src="cid:foo@bar".
var cidRe = regexp.MustCompile(`cid:([^"'\s>]+)`)
// rewriteCIDURLs replaces cid: references in HTML with local /msg/{id}/part/{cid} URLs.
func rewriteCIDURLs(html string, msgID int64) string {
return cidRe.ReplaceAllStringFunc(html, func(match string) string {
cid := strings.TrimPrefix(match, "cid:")
return "/msg/" + fmt.Sprintf("%d", msgID) + "/part/" + url.PathEscape(cid)
})
}
// fetchPartByCID finds the part with the given Content-ID in the message's
// BODYSTRUCTURE, fetches it, and returns the bytes and MIME type.
func fetchPartByCID(pool *globalPool, mbox Mailbox, uid uint32, cid string) ([]byte, string, error) {
tTotal := time.Now()
c, err := pool.acquire(mbox.Name)
if err != nil {
return nil, "", err
}
defer func() {
pool.release(c, mbox.Name)
log.Printf("imap: fetchPartByCID uid=%d cid=%s total=%s", uid, cid, time.Since(tTotal))
}()
seqset := new(imap.SeqSet)
seqset.AddNum(uid)
// Pass 1: fetch BODYSTRUCTURE to find the section with matching Content-ID.
t1 := time.Now()
bs, err := fetchBodyStructure(c, mbox, uid)
if err != nil {
return nil, "", err
}
log.Printf("imap: BODYSTRUCTURE uid=%d cid=%s took=%s", uid, cid, time.Since(t1))
// Walk the tree to find the part whose Content-ID matches.
// Content-IDs on the wire are wrapped in angle brackets: <foo@bar>.
wantID := "<" + cid + ">"
var foundPath []int
var foundMime, foundEncoding string
bs.Walk(func(path []int, part *imap.BodyStructure) bool {
if part.Id == wantID {
foundPath = make([]int, len(path))
copy(foundPath, path)
foundMime = strings.ToLower(part.MIMEType) + "/" + strings.ToLower(part.MIMESubType)
foundEncoding = part.Encoding
return false // stop walking
}
return true
})
if foundPath == nil {
return nil, "", fmt.Errorf("no part with Content-ID %q in uid %d", cid, uid)
}
// Pass 2: fetch the section.
sn, err := pathToSectionName(foundPath)
if err != nil {
return nil, "", fmt.Errorf("build section name: %w", err)
}
fetchItem := sn.FetchItem()
sectionKey := string(fetchItem)
msgs2 := make(chan *imap.Message, 1)
done2 := make(chan error, 1)
t2 := time.Now()
go func() { done2 <- c.UidFetch(seqset, []imap.FetchItem{imap.FetchUid, fetchItem}, msgs2) }()
var rawPart []byte
for msg := range msgs2 {
body, err := readMsgBody(msg)
if err != nil {
return nil, "", fmt.Errorf("read part: %w", err)
}
rawPart = body[sectionKey]
}
if err := <-done2; err != nil {
return nil, "", fmt.Errorf("part fetch: %w", err)
}
if rawPart == nil {
return nil, "", fmt.Errorf("server returned no data for section %s uid %d", sectionKey, uid)
}
log.Printf("imap: BODY[%s] uid=%d cid=%s took=%s", sectionKey, uid, cid, time.Since(t2))
decoded, err := decodePartBytes(rawPart, foundEncoding, "" /* images have no charset */)
if err != nil {
return nil, "", fmt.Errorf("decode part: %w", err)
}
return decoded, foundMime, nil
}
// fetchAttachment fetches one part of a message by its IMAP section path and
// returns the decoded bytes.
//
// The path comes from the attachments table, but the transfer encoding does
// not: it is a property of the part on the server, and storing it would mean
// keeping a second copy of something the server already tells us, which could
// then disagree after a mailbox is rebuilt. So the BODYSTRUCTURE is fetched
// again to read the encoding, and the recorded path is verified against it
// rather than trusted — if the message has changed shape, fetching the section
// that path now names would return the wrong part with no indication.
//
// Nothing is cached. An attachment is typically far larger than the message
// body and is wanted once, so keeping it would grow the database by exactly
// what the header-only design exists to avoid.
func fetchAttachment(pool *globalPool, mbox Mailbox, uid uint32, partPath string) ([]byte, string, error) {
tTotal := time.Now()
c, err := pool.acquire(mbox.Name)
if err != nil {
return nil, "", err
}
defer func() {
pool.release(c, mbox.Name)
log.Printf("imap: fetchAttachment uid=%d path=%s total=%s", uid, partPath, time.Since(tTotal))
}()
path, err := parsePathString(partPath)
if err != nil {
return nil, "", err
}
seqset := new(imap.SeqSet)
seqset.AddNum(uid)
// Pass 1: BODYSTRUCTURE, to learn the encoding and confirm the path.
bs, err := fetchBodyStructure(c, mbox, uid)
if err != nil {
return nil, "", err
}
part := partAtPath(bs, path)
if part == nil {
return nil, "", fmt.Errorf("no part %s in uid %d: the message no longer has the shape it was recorded with", partPath, uid)
}
mimeType := strings.ToLower(part.MIMEType) + "/" + strings.ToLower(part.MIMESubType)
// Pass 2: fetch the section itself.
sn, err := pathToSectionName(path)
if err != nil {
return nil, "", fmt.Errorf("build section name: %w", err)
}
fetchItem := sn.FetchItem()
sectionKey := string(fetchItem)
msgs2 := make(chan *imap.Message, 1)
done2 := make(chan error, 1)
go func() { done2 <- c.UidFetch(seqset, []imap.FetchItem{imap.FetchUid, fetchItem}, msgs2) }()
var raw []byte
for msg := range msgs2 {
body, err := readMsgBody(msg)
if err != nil {
<-done2
return nil, "", fmt.Errorf("read part: %w", err)
}
raw = body[sectionKey]
}
if err := <-done2; err != nil {
return nil, "", fmt.Errorf("part fetch: %w", err)
}
if raw == nil {
return nil, "", fmt.Errorf("server returned no data for section %s uid %d", sectionKey, uid)
}
// Charset is deliberately not applied: an attachment is handed over as the
// bytes it is, and its type travels with it in the Content-Type header. A
// text attachment declaring a charset keeps it there rather than being
// transcoded into something its declared type no longer describes.
decoded, err := decodePartBytes(raw, part.Encoding, "")
if err != nil {
return nil, "", fmt.Errorf("decode part: %w", err)
}
return decoded, mimeType, nil
}
// partAtPath resolves an IMAP section path against a body structure, returning
// nil when the path does not name a part of this message.
func partAtPath(bs *imap.BodyStructure, path []int) *imap.BodyStructure {
cur := bs
for _, n := range path {
if n < 1 || n > len(cur.Parts) {
return nil
}
cur = cur.Parts[n-1]
}
return cur
}
// renderPlainTextBody converts a plain-text email body to safe HTML.
// Lines starting with '>' (at any depth) are grouped into runs and collapsed
// into <details><summary>…</summary><pre>…</pre></details> blocks.
// The first line of each quoted run becomes the <summary>.
// All quoting depths are flattened — '>>' is treated the same as '>'.
// Normal (non-quoted) runs are rendered in a <pre> block.
func renderPlainTextBody(text string) template.HTML {
lines := strings.Split(text, "\n")
type run struct {
quoted bool
lines []string
}
// Group lines into quoted / normal runs.
var runs []run
for _, line := range lines {
isQuoted := strings.HasPrefix(line, ">")
if len(runs) == 0 || runs[len(runs)-1].quoted != isQuoted {
runs = append(runs, run{quoted: isQuoted})
}
runs[len(runs)-1].lines = append(runs[len(runs)-1].lines, line)
}
var buf strings.Builder
for _, r := range runs {
if !r.quoted {
buf.WriteString("<pre>")
buf.WriteString(template.HTMLEscapeString(strings.Join(r.lines, "\n")))
buf.WriteString("</pre>")
} else {
// Strip one leading '>' (and optional single space) from each line.
stripped := make([]string, len(r.lines))
for i, line := range r.lines {
line = strings.TrimPrefix(line, ">")
line = strings.TrimPrefix(line, " ")
stripped[i] = line
}
summary := template.HTMLEscapeString(stripped[0])
buf.WriteString("<details><summary>")
buf.WriteString(summary)
buf.WriteString("</summary>")
if len(stripped) > 1 {
buf.WriteString("<pre>")
buf.WriteString(template.HTMLEscapeString(strings.Join(stripped[1:], "\n")))
buf.WriteString("</pre>")
}
buf.WriteString("</details>")
}
}
return template.HTML(buf.String())
}
// fetchFullMessage fetches the complete raw RFC 5322 message bytes for a single
// UID from IMAP. Unlike fetchSingleBody it does not decode or cache — it returns
// the wire bytes suitable for forwarding as a message/rfc822 attachment.
//
// This fetch has no BODYSTRUCTURE pass to inherit fetchBodyStructure's retry
// from, so it repeats it: an empty answer is retried once against a refreshed
// session before it is reported. The retry is worth more here than anywhere
// else, because the caller is the spam report, which drops a message it cannot
// fetch and sends what it has. A stale connection would therefore file a
// complaint quietly missing the evidence it is about, having told nobody, and
// the recipient is a third party who cannot be unsent.
func fetchFullMessage(pool *globalPool, mbox Mailbox, uid uint32) ([]byte, error) {
c, err := pool.acquire(mbox.Name)
if err != nil {
return nil, err
}
defer pool.release(c, mbox.Name)
seqset := new(imap.SeqSet)
seqset.AddNum(uid)
attempt := func() ([]byte, error) {
msgs := make(chan *imap.Message, 1)
done := make(chan error, 1)
go func() { done <- c.UidFetch(seqset, []imap.FetchItem{imap.FetchUid, imap.FetchRFC822}, msgs) }()
var raw []byte
for msg := range msgs {
body, err := readMsgBody(msg)
if err != nil {
<-done
return nil, fmt.Errorf("read body uid=%d: %w", uid, err)
}
raw = body[string(imap.FetchRFC822)]
}
if err := <-done; err != nil {
return nil, fmt.Errorf("uid fetch uid=%d: %w", uid, err)
}
return raw, nil
}
raw, err := attempt()
if err != nil {
return nil, err
}
if raw != nil {
return raw, nil
}
if err := refreshSession(c, mbox, uid, "RFC822"); err != nil {
return nil, err
}
raw, err = attempt()
if err != nil {
return nil, err
}
if raw == nil {
return nil, expungedError(mbox, uid)
}
return raw, nil
}
|