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
package main

// Attachments.
//
// mailweb never downloads a message wholesale: it stores headers and fetches
// the single part it displays. That keeps a multi-year archive in megabytes,
// but it also meant attachments were invisible. An invoice arrived, the cover
// note rendered, and nothing anywhere said a PDF was attached — the only hint
// was the size annotation reporting megabytes for a mail that rendered to three
// lines, which reads as a bug rather than as an attachment.
//
// The information was already on the wire and being thrown away. Displaying a
// message requires its BODYSTRUCTURE, which describes every part of the message
// including the ones not displayed; findDisplayPart consumed that tree, picked
// one part and discarded the rest. Recording the rest costs no extra round-trip
// and is what lets a listing name what is there and hand out a URL for it.
//
// What is *not* done here is fetching or converting attachment content. The
// listing gives type, size, filename and location, and a reader decides whether
// a PDF is worth retrieving and what to do with it. Extracting text would mean
// running a document parser over attacker-controlled bytes on behalf of an
// unauthenticated GET, which is a great deal of new attack surface for a
// convenience that the reader can arrange for itself.

import (
	"bytes"
	"database/sql"
	"fmt"
	"strconv"
	"strings"
	"time"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"

	"github.com/emersion/go-imap"
)

// attachment is one non-displayed part of a message.
type attachment struct {
	Idx         int    // 1-based position, the handle used in URLs
	PartPath    string // IMAP section path, e.g. "2.1"
	MimeType    string // lower-cased "type/subtype"
	Filename    string // may be "" — many parts carry no name
	Size        int64  // octets on the wire, as reported by the server
	Disposition string // "attachment", "inline", or ""
	CID         string // Content-ID without angle brackets, may be ""
}

// URL is where this attachment can be fetched. Positional, because filenames
// repeat within a message and may be missing entirely.
func (a attachment) URL(msgID int64) string {
	return fmt.Sprintf("/msg/%d/attachment/%d", msgID, a.Idx)
}

// DisplayName is what to call the attachment in a listing. A part with no
// filename still has a type, which is more useful than an empty column.
func (a attachment) DisplayName() string {
	if n := strings.TrimSpace(a.Filename); n != "" {
		return mailtext.CollapseSpaces(n)
	}
	return "(unnamed " + a.MimeType + ")"
}

// IsCalendar says the part claims to be an iCalendar file, so a view can offer
// its summary. The judgement is isCalendarPart's; this exists because templates
// call methods, not functions.
func (a attachment) IsCalendar() bool { return isCalendarPart(a) }

// MaybePDF says the part claims to be a PDF, so a view can offer the link that
// displays it rather than saving it.
//
// This decides only what is *offered*: both fields it reads are written by the
// sender and neither is verified here. What the link leads to checks the bytes
// themselves and refuses anything that is not a PDF, so a part that lies its
// way past this predicate gets a 404 rather than a rendering. That split is the
// same one isCalendarPart makes — offer generously, act only on what is really
// there — and it matters more here, because the acting hands bytes to a
// renderer instead of to a parser mailweb wrote.
//
// The filename is consulted because the declared type is wrong in bulk: 47 of
// the PDFs in this archive are labelled application/octet-stream, which no
// browser will ever display inline. Selecting on the type alone would withhold
// the feature from exactly the mail that has it — scanned invoices, which is
// most of what a PDF here is.
func (a attachment) MaybePDF() bool { return maybePDFPart(a) }

// InlineURL is where this attachment can be displayed rather than saved. Only
// meaningful when MaybePDF is true; the route 404s for anything else.
func (a attachment) InlineURL(msgID int64) string {
	return fmt.Sprintf("/msg/%d/attachment/%d/inline", msgID, a.Idx)
}

// maxFramedPDF is the size above which a PDF is linked but not framed.
//
// Attachments are never cached, so a frame is an uncached IMAP fetch of the
// whole part, and the message page waits for it. Most PDFs here are invoices of
// a few hundred KB, for which that is imperceptible; the largest is 32M, and
// eight exceed 2M. Framing those would mean a page that hangs on something the
// reader may not have wanted to look at, which is how a feature meant to save a
// download turns into a reason to avoid opening the message.
//
// The link is still offered above the ceiling — see Framable. The reader can
// still see the file, having been told what it costs by the size beside it.
const maxFramedPDF = 5 << 20 // 5 MiB

// Framable says whether to draw this attachment in a frame, as opposed to
// merely linking to it.
//
// Both conditions are the point. A part that is not a PDF has nothing to frame,
// and a part that is too large would make the page wait on it. A recorded size
// of zero means the size was never recorded rather than that the part is empty,
// so it is framed: refusing on a missing figure would silently withhold the
// feature from every message synced before sizes were stored.
func (a attachment) Framable() bool {
	return a.MaybePDF() && a.Size <= maxFramedPDF
}

// SizeHint renders the size the way listings render message sizes. Unlike
// RFC822.SIZE this figure is exact — it is the size of this part on the wire —
// but it is still the *encoded* size, and base64 inflates by about a third, so
// it is marked approximate for the same reason and read the same way.
func (a attachment) SizeHint() string {
	s := mailtext.FormatSize(a.Size)
	if s == "" {
		return ""
	}
	return "~" + s
}

// collectAttachments walks a BODYSTRUCTURE and returns the parts that count as
// attachments: every leaf that is neither the part being displayed nor an
// inline image already reachable through /part/{cid}.
//
// The display part is excluded because it is the message body, which is
// rendered in full directly above the attachment list; listing it again as an
// attachment would suggest there is something further to fetch when there is
// not.
//
// Inline images with a Content-ID are excluded because they are the message's
// own illustrations, already referenced from the body and already served by
// /msg/{id}/part/{cid}. A newsletter contains dozens, and listing each one
// buries the single PDF that a reader actually cares about. An inline image
// with no Content-ID cannot be referenced from the body, so it *is* listed:
// nothing else would ever mention it.
//
// The other children of a multipart/alternative are excluded, and this is the
// case that matters most in practice. That container asserts that its children
// are *the same message* in different formats: the plain-text twin of an HTML
// mail is not something further to read, it is the thing already displayed,
// written out again. Counting it would have put an attachment on essentially
// every newsletter in the archive — 25922 of them on this account — and a list
// that fires on everything tells a reader nothing.
//
// message/rfc822 parts are attachments in their own right and are not
// descended into. A forwarded mail is one thing to fetch, not a tree of parts
// whose numbering would collide with the outer message's.
func collectAttachments(bs *imap.BodyStructure, display displayPart) []attachment {
	displayPath := pathString(display.path)

	var out []attachment
	var walk func(part *imap.BodyStructure, path []int, inAlternative bool)
	walk = func(part *imap.BodyStructure, path []int, inAlternative bool) {
		mt := strings.ToLower(part.MIMEType)
		mst := strings.ToLower(part.MIMESubType)

		// Descend through multipart containers; they are structure, not content.
		// message/rfc822 also has children, but is itself the thing to fetch.
		if mt == "multipart" {
			childAlternative := mst == "alternative"
			for i, child := range part.Parts {
				walk(child, append(append([]int{}, path...), i+1), childAlternative)
			}
			return
		}

		// The root of a non-multipart message has an empty path: the whole
		// message is the display part, so there is nothing else to list.
		if len(path) == 0 {
			return
		}

		p := pathString(path)
		if p == displayPath {
			return
		}

		disposition := strings.ToLower(strings.TrimSpace(part.Disposition))
		cid := strings.Trim(part.Id, "<>")

		// A sibling within multipart/alternative is the displayed content in
		// another format, not a further thing to read. An explicit
		// Content-Disposition of attachment overrides this: a sender who says a
		// part is an attachment is believed, since that is no longer a claim of
		// equivalence.
		if inAlternative && disposition != "attachment" {
			return
		}

		// An inline image the body can reference is served elsewhere.
		if mt == "image" && cid != "" && disposition != "attachment" {
			return
		}

		out = append(out, attachment{
			Idx:         len(out) + 1,
			PartPath:    p,
			MimeType:    mt + "/" + mst,
			Filename:    attachmentFilename(part),
			Size:        int64(part.Size),
			Disposition: disposition,
			CID:         cid,
		})
	}
	walk(bs, []int{}, false)
	return out
}

// attachmentFilename recovers the name of a part.
//
// The name lives in Content-Disposition's filename parameter, and older senders
// put it in Content-Type's name parameter instead; both are common enough that
// ignoring either loses names in practice. Any directory component is dropped:
// some senders include a full path from their own machine, which is both noise
// and something no consumer should treat as a path.
func attachmentFilename(part *imap.BodyStructure) string {
	name := ""
	for _, k := range []string{"filename", "FILENAME"} {
		if v, ok := part.DispositionParams[k]; ok && strings.TrimSpace(v) != "" {
			name = v
			break
		}
	}
	if name == "" {
		for _, k := range []string{"name", "NAME"} {
			if v, ok := part.Params[k]; ok && strings.TrimSpace(v) != "" {
				name = v
				break
			}
		}
	}
	name = strings.TrimSpace(name)
	if i := strings.LastIndexAny(name, `/\`); i >= 0 {
		name = name[i+1:]
	}
	return name
}

// maybePDFPart says whether an attachment is worth offering a display link for.
// See attachment.MaybePDF for why both fields are consulted and why neither is
// trusted.
func maybePDFPart(a attachment) bool {
	if strings.ToLower(strings.TrimSpace(a.MimeType)) == "application/pdf" {
		return true
	}
	return strings.HasSuffix(strings.ToLower(strings.TrimSpace(a.Filename)), ".pdf")
}

// pdfMagic is the header every PDF begins with, per ISO 32000-1 §7.5.2.
const pdfMagic = "%PDF-"

// looksLikePDF checks the bytes rather than the sender's word for them.
//
// This is what licenses serving the part with a Content-Type of application/pdf
// and a disposition of inline. Both are assertions mailweb makes in its own
// voice — the sender frequently said octet-stream, or said nothing — and an
// assertion about a type is only honest if something checked the type. Without
// the check this route would take any part named foo.pdf and tell the browser
// to render it, which is the difference between delegating to a PDF viewer and
// handing arbitrary bytes to whatever the browser guesses they are.
//
// A leading BOM or stray whitespace is tolerated because real files have it;
// beyond that the header must be the first thing in the file. The check is
// deliberately shallow: it establishes that the bytes claim, in their own
// structure, to be the thing the header will say they are. Validating further
// would mean parsing the container, which is the thing this route exists to
// avoid doing.
func looksLikePDF(data []byte) bool {
	b := bytes.TrimLeft(data, "\x00 \t\r\n")
	b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf"))
	return bytes.HasPrefix(b, []byte(pdfMagic))
}

// pathString renders an IMAP section path as "2.1". The empty path renders as
// "", which is the root of a non-multipart message.
func pathString(path []int) string {
	parts := make([]string, len(path))
	for i, n := range path {
		parts[i] = strconv.Itoa(n)
	}
	return strings.Join(parts, ".")
}

// parsePathString is the inverse, for turning a stored part_path back into the
// path a fetch needs.
func parsePathString(s string) ([]int, error) {
	if s == "" {
		return nil, fmt.Errorf("empty part path")
	}
	fields := strings.Split(s, ".")
	path := make([]int, len(fields))
	for i, f := range fields {
		n, err := strconv.Atoi(f)
		if err != nil || n < 1 {
			return nil, fmt.Errorf("invalid part path %q", s)
		}
		path[i] = n
	}
	return path, nil
}

// recordAttachments stores the attachment list of a message and marks its
// structure as examined.
//
// Recording is idempotent: the rows are replaced wholesale, so re-examining a
// message after its structure was already known cannot accumulate duplicates.
//
// Callers treat a failure here as non-fatal. This runs as a side effect of
// displaying a message, and reading mail must not fail because a note about
// what is attached could not be written.
func recordAttachments(db *sql.DB, msgID int64, atts []attachment) error {
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("begin: %w", err)
	}
	defer tx.Rollback()

	if _, err := tx.Exec(`DELETE FROM attachments WHERE message_id = ?`, msgID); err != nil {
		return fmt.Errorf("clear attachments: %w", err)
	}
	for _, a := range atts {
		if _, err := tx.Exec(
			`INSERT INTO attachments
			   (message_id, idx, part_path, mime_type, filename, size, disposition, cid)
			 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
			msgID, a.Idx, a.PartPath, a.MimeType,
			nullIfEmpty(a.Filename), a.Size,
			nullIfEmpty(a.Disposition), nullIfEmpty(a.CID),
		); err != nil {
			return fmt.Errorf("insert attachment %d: %w", a.Idx, err)
		}
	}
	if _, err := tx.Exec(
		`UPDATE messages SET bodystructure_scanned_at = ? WHERE id = ?`,
		time.Now().Unix(), msgID,
	); err != nil {
		return fmt.Errorf("mark scanned: %w", err)
	}
	return tx.Commit()
}

// nullIfEmpty stores an absent string as NULL rather than "", so that the
// database distinguishes "no filename" from "a filename that is blank".
func nullIfEmpty(s string) any {
	if s == "" {
		return nil
	}
	return s
}

// loadAttachments returns the recorded attachments of a message, and whether
// its structure has been examined at all.
//
// The second return value is the point of the function. An empty list means
// "no attachments" only if the message has been scanned; otherwise it means
// "not known", and a view that conflates the two tells its reader there is
// nothing attached to a message that may well have something attached.
func loadAttachments(db *sql.DB, msgID int64) (atts []attachment, scanned bool, err error) {
	var scannedAt sql.NullInt64
	if err := db.QueryRow(
		`SELECT bodystructure_scanned_at FROM messages WHERE id = ?`, msgID,
	).Scan(&scannedAt); err != nil {
		return nil, false, fmt.Errorf("scan state: %w", err)
	}
	if !scannedAt.Valid {
		return nil, false, nil
	}

	rows, err := db.Query(
		`SELECT idx, part_path, mime_type, filename, size, disposition, cid
		   FROM attachments WHERE message_id = ? ORDER BY idx`, msgID)
	if err != nil {
		return nil, true, fmt.Errorf("query attachments: %w", err)
	}
	defer rows.Close()

	for rows.Next() {
		var a attachment
		var filename, disposition, cid sql.NullString
		var size sql.NullInt64
		if err := rows.Scan(&a.Idx, &a.PartPath, &a.MimeType,
			&filename, &size, &disposition, &cid); err != nil {
			return nil, true, fmt.Errorf("scan attachment: %w", err)
		}
		a.Filename = filename.String
		a.Size = size.Int64
		a.Disposition = disposition.String
		a.CID = cid.String
		atts = append(atts, a)
	}
	return atts, true, rows.Err()
}

// loadAttachmentsBatch is loadAttachments for a whole page of messages: one
// query for the list and one for the scan state, rather than two per message.
//
// The listings render ten to fifty messages at a time, and a per-message lookup
// there is the same mistake loadUnsubscribeInfo exists to avoid. The second map
// carries the same distinction the second return value of loadAttachments does:
// an id absent from it has never been examined, and a view that reads a missing
// entry as "no attachments" reports an absence it cannot know.
func loadAttachmentsBatch(db *sql.DB, ids []int64) (map[int64][]attachment, map[int64]bool, error) {
	if len(ids) == 0 {
		return nil, nil, nil
	}
	placeholders := make([]string, len(ids))
	args := make([]any, len(ids))
	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id
	}
	in := "(" + strings.Join(placeholders, ",") + ")"

	scanned := make(map[int64]bool, len(ids))
	rows, err := db.Query(
		`SELECT id FROM messages
		  WHERE bodystructure_scanned_at IS NOT NULL AND id IN `+in, args...)
	if err != nil {
		return nil, nil, fmt.Errorf("query scan state: %w", err)
	}
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return nil, nil, fmt.Errorf("scan state: %w", err)
		}
		scanned[id] = true
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return nil, nil, err
	}

	rows, err = db.Query(
		`SELECT message_id, idx, part_path, mime_type, filename, size, disposition, cid
		   FROM attachments WHERE message_id IN `+in+` ORDER BY message_id, idx`, args...)
	if err != nil {
		return nil, nil, fmt.Errorf("query attachments: %w", err)
	}
	defer rows.Close()

	byMsg := make(map[int64][]attachment)
	for rows.Next() {
		var msgID int64
		var a attachment
		var filename, disposition, cid sql.NullString
		var size sql.NullInt64
		if err := rows.Scan(&msgID, &a.Idx, &a.PartPath, &a.MimeType,
			&filename, &size, &disposition, &cid); err != nil {
			return nil, nil, fmt.Errorf("scan attachment: %w", err)
		}
		a.Filename = filename.String
		a.Size = size.Int64
		a.Disposition = disposition.String
		a.CID = cid.String
		byMsg[msgID] = append(byMsg[msgID], a)
	}
	return byMsg, scanned, rows.Err()
}

// attachmentBox is what templates/attachments.html renders: one message's
// attachments, whether its structure has been examined, and the id the fetch
// URLs are built from.
//
// It exists because a template cannot be handed three values, and because the
// pairing is the point: a list without its scan state is a list that reads as
// "nothing attached" when it means "not looked".
type attachmentBox struct {
	MsgID       int64
	Attachments []attachment
	Known       bool
	// Viewer says a PDF viewer is stored, so a PDF may be framed rather than
	// only linked. It is false on a fresh database and whenever the download
	// has not succeeded, and the frame is then simply not drawn — the listings
	// leave it false always, since they never frame a PDF.
	Viewer bool
}

// withViewer marks a box as able to frame its PDFs.
//
// Separate from attachmentBoxFor because only one view frames anything: the
// listings build boxes too, and threading a viewer flag through them would
// invite somebody to set it there, where a frame per message would be an
// uncached IMAP fetch per message.
func withViewer(box attachmentBox, viewer bool) attachmentBox {
	box.Viewer = viewer
	return box
}

// attachmentBoxFor pulls one message's box out of the maps a batch load
// returned. A nil map is the failed-lookup case and yields Known=false, which
// renders as "not yet known" rather than as an absence.
func attachmentBoxFor(byMsg map[int64][]attachment, scanned map[int64]bool, id int64) attachmentBox {
	return attachmentBox{MsgID: id, Attachments: byMsg[id], Known: scanned[id]}
}

// attachmentBoxes builds one box per message on a page, for the listings.
//
// Every id gets an entry, including the ones with nothing attached and the ones
// never examined. A template that looked up a missing key would render the zero
// box, whose MsgID is 0 — so the fetch links would point at message 0 — and this
// is cheaper to guarantee here than to remember in three templates.
func attachmentBoxes(ids []int64, byMsg map[int64][]attachment, scanned map[int64]bool) map[int64]attachmentBox {
	boxes := make(map[int64]attachmentBox, len(ids))
	for _, id := range ids {
		boxes[id] = attachmentBoxFor(byMsg, scanned, id)
	}
	return boxes
}

// loadAttachment returns one attachment by its position within a message.
func loadAttachment(db *sql.DB, msgID int64, idx int) (attachment, error) {
	var a attachment
	var filename, disposition, cid sql.NullString
	var size sql.NullInt64
	err := db.QueryRow(
		`SELECT idx, part_path, mime_type, filename, size, disposition, cid
		   FROM attachments WHERE message_id = ? AND idx = ?`, msgID, idx,
	).Scan(&a.Idx, &a.PartPath, &a.MimeType, &filename, &size, &disposition, &cid)
	if err != nil {
		return attachment{}, err
	}
	a.Filename = filename.String
	a.Size = size.Int64
	a.Disposition = disposition.String
	a.CID = cid.String
	return a, nil
}