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
|
{{/*
What is attached to a message, shared by every HTML view that shows one.
This is the HTML counterpart of the attachments region in msgbody.llm, and it
exists because that region had no counterpart for two releases: the text
rendering named the PDF, the browser said nothing at all. A cover note read
"see attached" and the page below it was three lines and a reply button, while
the only hint that anything came with it was a size annotation the HTML views
did not print either.
It renders *outside* the message frame, never inside it. A message body is the
sender's own HTML, and a box mailweb draws inside that document is a box the
sender can draw too — an attachment list is exactly the thing worth forging,
since every row is a link the reader is invited to click. Outside the frame it
is in mailweb's own document, where the sender has no markup at all. This is
the same argument as the random marker token in the text rendering, in the
medium that has a stronger version of it available.
Takes an attachmentBox: the list, whether the message has been examined, and
the id the fetch URLs are built from.
Two templates, drawn in different places. "calendars" goes *above* the message
body and "attachments" below it; see the comment on "calendars" for why the
two are not together.
*/}}
{{/*
The events an attached calendar describes, drawn above the message body.
Above, because for most mail that carries one the event is the message and the
body is the covering letter. A booking confirmation says "your booking is
confirmed, see attached" in three paragraphs of boilerplate, and the fact
worth having — a train at 23:35, a cancelled car, a meeting on Wednesday — is
in the attachment. Under the body it was below a screenful of nothing.
The file itself still appears in the attachment list below, so the bytes are
where bytes have always been. What moved is the summary, not the download.
loading="lazy" is kept even though this is now near the top of each message:
it is per-frame and viewport-driven, so the first message's calendar loads and
the fortieth still does not. That is the property that keeps the 59-attachment
contact page cheap, and it does not depend on where in the message the frame
sits.
*/}}
{{define "calendars"}}
{{- if .Known}}
{{- range .Attachments}}
{{- if .IsCalendar}}
<iframe class="att-cal" src="{{.URL $.MsgID}}/calendar"
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
loading="lazy" title="What this calendar file contains"
onload="resizeIframe(this)"></iframe>
{{- end}}
{{- end}}
{{- end}}
{{end}}
{{define "attachments"}}
{{- if .Known}}
{{- with .Attachments}}
<ul class="atts">
{{- range .}}
<li>
<div class="att-row">
<a class="att" href="{{.URL $.MsgID}}" download
title="Fetch this attachment. Served as the bytes it is, unconverted.">
<span class="att-clip" aria-hidden="true">📎</span>{{.DisplayName}}</a>
<span class="att-meta">{{.MimeType}}{{with .SizeHint}} · {{.}}{{end}}</span>
{{/*
A PDF additionally offers to be looked at, since the answer to "what
does this invoice say" was otherwise a download and another
application — the same complaint the calendar summary answers, for the
format that is 270 messages here rather than 127.
A link and not a frame. Displaying a PDF means running a PDF viewer,
and every browser's viewer is script, so a frame holding one cannot
have the sandbox that makes a message body safe. As a top-level
document in a new tab there is no frame to escape from and nothing of
mailweb's alongside it to reach; the response additionally carries a
CSP of `sandbox`, so it is an opaque origin even there.
Offered on the sender's say-so — a type of application/pdf or a .pdf
name — while the route itself checks the bytes and 404s if they are not
a PDF. The generous test is what reaches the 47 PDFs whose senders
called them octet-stream; the strict one is what keeps mailweb from
asserting a type nothing verified.
*/}}
{{- if .MaybePDF}}
<a class="att-view" href="{{.InlineURL $.MsgID}}"
target="_blank" rel="noopener noreferrer"
title="Display this PDF in a new tab, using the browser's own viewer. mailweb does not read the file; it checks that the bytes really are a PDF and hands them over.">view</a>
{{- else if .MaybeText}}
<a class="att-view" href="{{.TextURL $.MsgID}}"
target="_blank" rel="noopener noreferrer"
title="Read this file as text in a new tab. mailweb applies its declared charset and serves it as plain text; nothing in it is rendered as markup.">view</a>
{{- end}}
</div>
{{/* The events this file describes are drawn by the "calendars" template,
above the message body rather than here. What stays here is the file
itself: the summary answers "what is this about", the row answers "give
me the bytes", and only the second belongs in a list of attachments. */}}
{{/*
A text file's contents, directly under its row, so the name, the link and
the text are one item rather than the same file listed twice.
No frame, unlike a message body or a PDF. A frame is what keeps a
sender's markup, or a viewer's script, out of mailweb's document; a text
file shown as text has neither. The handler has already decoded it to
UTF-8 and refused anything that is not text, and html/template escapes
it here, so every byte lands in mailweb's page as character data inside
a <pre> — the frame's guarantee got by escaping rather than separation.
The bordered box is what keeps it from reading as mailweb speaking.
Only where .ShowsText is set, which is the message page alone: each text
is an uncached IMAP fetch made while the page renders. The listings get
the row's link.
*/}}
{{- if and $.ShowsText .TextFramable}}
{{- if $.HasText .Idx}}
<pre class="att-text-body">{{$.Text .Idx}}</pre>
{{- else}}
<p class="atts-unknown">this file could not be fetched, or does not decode as text</p>
{{- end}}
{{- end}}
</li>
{{- end}}
</ul>
{{- end}}
{{- else}}
{{/*
Never silence. An empty list means "nothing attached" only for a message whose
structure has been examined; for one that has not, saying nothing would assert
an absence mailweb cannot know. That distinction is the entire reason
bodystructure_scanned_at exists, and dropping it here would put the lie in the
one place a reader would believe it.
*/}}
<p class="atts-unknown"
title="mailweb stores headers and fetches one part on demand, so it has never seen this message's structure. Opening the message records it; --analyze does so for the whole archive.">
attachments not yet known
</p>
{{- end}}
{{end}}
{{/*
Attached PDFs, drawn as frames below the message body.
Below, unlike "calendars", because the two formats stand in opposite relations
to the mail that carries them. A calendar invitation *is* the message and the
text is the covering letter, so its summary goes above. A PDF is an enclosure:
the mail says "your invoice is attached" and means it, so the body is read
first and the document after it.
What renders it is pdf.js, vendored and served from /static/pdfjs/ rather than
left to the browser. The browser's own viewer cannot be used here: Chromium
refuses to instantiate it inside any sandboxed iframe — measured, not assumed,
the bytes arriving with a 200 and the load then failing with
ERR_BLOCKED_BY_CLIENT. No combination of sandbox tokens changes that, since
what blocks it is the HTML spec's sandboxed plugins browsing context flag, for
which no allow-* keyword exists. pdf.js is ordinary JavaScript and never
engages that flag, so shipping the thing Firefox already has is what makes the
frame work in both browsers.
The frame is drawn only when a viewer has actually been stored. On a fresh
database, or when the download has not succeeded, .Viewer is false and the
attachment row's link is all there is — which is where this feature started
and is a working answer, not a broken one.
loading="lazy" for the same reason the calendar frames have it: an attachment
is never cached, so each frame is an IMAP round-trip, and one that is scrolled
past should not be paid for.
*/}}
{{define "pdfs"}}
{{- if and .Known .Viewer}}
{{- range .Attachments}}
{{- if .Framable}}
<figure class="att-pdf">
{{/*
The sandbox withholds allow-same-origin deliberately, and this is the whole
security argument for framing a PDF at all.
A PDF viewer is script, so unlike a message body this frame cannot be denied
script — script is what draws the document. What it is denied instead is the
origin: with allow-scripts but not allow-same-origin the frame is an opaque
origin, so a bug in the viewer (pdf.js has had them: CVE-2024-4367 gave
arbitrary script execution in the embedding origin) lands somewhere that
cannot read mailweb's DOM, its pages, or anything else of the account's.
Granting both together was measured, not merely argued about: a frame with
allow-scripts and allow-same-origin read the parent document's title and
fetched 4345 bytes of mailweb's own page from inside the frame. Chromium's
console says the same thing in one line — "An iframe which has both
allow-scripts and allow-same-origin for its sandbox attribute can escape its
sandboxing." mailweb has no authentication and no CSRF protection, so script
in its origin is the whole archive plus the ability to send mail. The two
must never be granted together; see "SECURITY" in mailweb(1).
The cost of withholding it is that resizeIframe cannot read this frame's
height, so the frame has a fixed one and the viewer scrolls inside it. That
is the correct trade and not a limitation worth engineering around.
allow-downloads so the viewer's own save button works; the attachment row
below offers the same bytes, but a reader who has scrolled to page nine of a
document should not have to go looking for the row.
*/}}
<iframe class="att-pdf-frame" src="/static/pdfjs/web/viewer.html?file={{.InlineURL $.MsgID | urlquery}}"
sandbox="allow-scripts allow-downloads allow-popups allow-popups-to-escape-sandbox"
loading="lazy" title="{{.DisplayName}}"></iframe>
<figcaption>
{{.DisplayName}} —
<a href="{{.InlineURL $.MsgID}}" target="_blank" rel="noopener noreferrer">open in a new tab</a>
</figcaption>
</figure>
{{- end}}
{{- end}}
{{- end}}
{{end}}
{{define "attachmentStyle"}}
ul.atts { list-style: none; margin: 0.5rem 0 0; padding: 0; }
ul.atts li { font-size: 0.85rem; padding: 0.15rem 0; }
.att-row { display: flex; align-items: baseline; gap: 0.5rem; }
iframe.att-cal {
width: 100%; border: none; display: block;
margin: 0.25rem 0 0.4rem; min-height: 2.5rem;
}
a.att { color: var(--link); text-decoration: none; }
a.att:hover { text-decoration: underline; }
a.att-view {
color: var(--text-meta); text-decoration: none; font-size: 0.78rem;
border: 1px solid var(--border); border-radius: 3px;
padding: 0.05rem 0.35rem;
}
a.att-view:hover { color: var(--text); border-color: var(--text-meta); }
.att-clip { margin-right: 0.35em; }
.att-meta { color: var(--text-meta); font-size: 0.78rem; }
.atts-unknown {
margin: 0.5rem 0 0; font-size: 0.78rem;
color: var(--text-meta); font-style: italic;
}
figure.att-pdf { margin: 0.8rem 0 0; }
/* A fixed height, because the frame is an opaque origin and its content
height cannot be read. The viewer scrolls within it. */
iframe.att-pdf-frame {
width: 100%; height: 40rem; border: 1px solid var(--border);
border-radius: 3px; display: block; background: var(--bg);
}
figure.att-pdf figcaption {
font-size: 0.78rem; color: var(--text-meta); margin-top: 0.3rem;
}
pre.att-text-body {
margin: 0.3rem 0 0.5rem; max-height: 30rem; overflow: auto;
border: 1px solid var(--border); border-radius: 3px;
padding: 0.5rem 0.7rem; font-size: 0.8rem;
white-space: pre-wrap; word-break: break-word;
}
{{end}}
|