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