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
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 (
	"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 + ")"
}

// 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
}

// 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()
}

// 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
}