@wtfalch/email 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { Attachment } from './types.ts';
3
+ /**
4
+ * Fetching an attachment's bytes.
5
+ *
6
+ * **This module exists because a download URL is not a link.** JMAP's
7
+ * `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
8
+ * same authentication the API uses — so `<a href={downloadUrl} download>` and
9
+ * `<img src={cid}>` both fetch it without an `Authorization` header and get a
10
+ * 401. The bytes have to be fetched by code that holds the token, and the
11
+ * result handed to the browser as a blob it already has.
12
+ *
13
+ * That is the whole reason attachments were unreachable: `ThreadView` has
14
+ * taken an `onDownload` since it was written, and nothing could supply one
15
+ * without this.
16
+ *
17
+ * **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
18
+ * refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
19
+ * well as `Bearer`, and a browser allowed to use credentials honours the
20
+ * second by opening its native password prompt — a modal that hides whatever
21
+ * the application was about to say. Declining that here keeps a 401 an
22
+ * ordinary response.
23
+ */
24
+ /** How much of a message this will pull into memory before refusing. A mail
25
+ * server will happily serve a 2GB blob and a browser tab will not survive
26
+ * holding one; Stalwart's own default message size limit is well under this.
27
+ * A caller that genuinely wants more passes its own `maxBytes`. */
28
+ export declare const DEFAULT_MAX_DOWNLOAD_BYTES: number;
29
+ export type DownloadOptions = {
30
+ /** Refuse a blob larger than this, rather than exhausting the tab. */
31
+ maxBytes?: number;
32
+ /** Abort the fetch — a person navigating away from a slow attachment. */
33
+ signal?: AbortSignal;
34
+ };
35
+ /**
36
+ * An attachment's bytes, as a `Blob`.
37
+ *
38
+ * The type comes from the message rather than from the response: a server
39
+ * that sends `application/octet-stream` for everything would otherwise turn
40
+ * every PDF into a file the operating system cannot open, and the part's
41
+ * declared type is the one the sender chose.
42
+ */
43
+ export declare function downloadAttachment(client: MailClient, attachment: Pick<Attachment, 'blobId' | 'name' | 'type' | 'size' | 'downloadUrl'>, options?: DownloadOptions): Promise<Blob>;
@@ -0,0 +1,68 @@
1
+ import { MailError, guard } from "./errors.js";
2
+ /**
3
+ * Fetching an attachment's bytes.
4
+ *
5
+ * **This module exists because a download URL is not a link.** JMAP's
6
+ * `downloadUrl` carries no credential of its own — RFC 8620 §6.2 expects the
7
+ * same authentication the API uses — so `<a href={downloadUrl} download>` and
8
+ * `<img src={cid}>` both fetch it without an `Authorization` header and get a
9
+ * 401. The bytes have to be fetched by code that holds the token, and the
10
+ * result handed to the browser as a blob it already has.
11
+ *
12
+ * That is the whole reason attachments were unreachable: `ThreadView` has
13
+ * taken an `onDownload` since it was written, and nothing could supply one
14
+ * without this.
15
+ *
16
+ * **`credentials: 'omit'`, for the reason `client.ts` gives at length.** A
17
+ * refused request makes Stalwart answer with `WWW-Authenticate: Basic` as
18
+ * well as `Bearer`, and a browser allowed to use credentials honours the
19
+ * second by opening its native password prompt — a modal that hides whatever
20
+ * the application was about to say. Declining that here keeps a 401 an
21
+ * ordinary response.
22
+ */
23
+ /** How much of a message this will pull into memory before refusing. A mail
24
+ * server will happily serve a 2GB blob and a browser tab will not survive
25
+ * holding one; Stalwart's own default message size limit is well under this.
26
+ * A caller that genuinely wants more passes its own `maxBytes`. */
27
+ export const DEFAULT_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
28
+ /**
29
+ * An attachment's bytes, as a `Blob`.
30
+ *
31
+ * The type comes from the message rather than from the response: a server
32
+ * that sends `application/octet-stream` for everything would otherwise turn
33
+ * every PDF into a file the operating system cannot open, and the part's
34
+ * declared type is the one the sender chose.
35
+ */
36
+ export async function downloadAttachment(client, attachment, options = {}) {
37
+ const max = options.maxBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
38
+ /* Checked before the request as well as after. The message says how big
39
+ the part is, and refusing on that alone saves pulling a gigabyte over
40
+ the wire to then refuse it. */
41
+ if (attachment.size > max) {
42
+ throw new MailError(`${attachment.name} is ${attachment.size} bytes, over the ${max}-byte limit for a download`, { type: 'tooLarge', operation: 'download' });
43
+ }
44
+ /* The URL on the attachment was expanded when the message was read. A
45
+ caller that built an `Attachment` by hand may not have one, so it is
46
+ recomputed rather than assumed. */
47
+ const url = attachment.downloadUrl || (await client.downloadUrl(attachment));
48
+ return guard('download', async () => {
49
+ const response = await fetch(url, {
50
+ headers: { Authorization: client.authorization },
51
+ credentials: 'omit',
52
+ ...(options.signal ? { signal: options.signal } : {}),
53
+ });
54
+ if (!response.ok) {
55
+ throw new MailError(`the server refused to send ${attachment.name}`, {
56
+ status: response.status,
57
+ operation: 'download',
58
+ });
59
+ }
60
+ const bytes = await response.arrayBuffer();
61
+ /* A server is not obliged to send `Content-Length`, and a chunked
62
+ response can be any size whatever the message claimed. */
63
+ if (bytes.byteLength > max) {
64
+ throw new MailError(`${attachment.name} arrived at ${bytes.byteLength} bytes, over the ${max}-byte limit`, { type: 'tooLarge', operation: 'download' });
65
+ }
66
+ return new Blob([bytes], { type: attachment.type || 'application/octet-stream' });
67
+ });
68
+ }
@@ -262,7 +262,62 @@ export class FakeMailbox {
262
262
  this.#threads.set(threadId, [id]);
263
263
  created[key] = { id, blobId: `blob-${id}`, threadId, size: 512 };
264
264
  }
265
- return { accountId: ACCOUNT, oldState: '1', newState: '2', created, notCreated: null };
265
+ /* The patch language, enough of it for what `mutate.ts` sends:
266
+ `keywords/$seen`, a whole `mailboxIds` replacement, and
267
+ `mailboxIds/<id>` removal. A `null` value deletes rather than sets
268
+ false, which is RFC 8620 §5.3's rule and the one a fake gets wrong
269
+ by treating the patch as a merge -- `keywords: { $seen: false }`
270
+ reads as unread everywhere in this package, so a fake that stores
271
+ the false would pass a test the real server fails. */
272
+ const update = (args.update ?? {});
273
+ const updated = {};
274
+ const notUpdated = {};
275
+ for (const [id, patch] of Object.entries(update)) {
276
+ const email = this.#emails.find((one) => one.id === id);
277
+ if (!email) {
278
+ notUpdated[id] = { type: 'notFound' };
279
+ continue;
280
+ }
281
+ for (const [path, value] of Object.entries(patch)) {
282
+ if (path === 'mailboxIds') {
283
+ email.mailboxIds = { ...value };
284
+ }
285
+ else if (path.startsWith('mailboxIds/')) {
286
+ const boxId = unpointer(path.slice('mailboxIds/'.length));
287
+ const next = { ...email.mailboxIds };
288
+ if (value === null)
289
+ delete next[boxId];
290
+ else
291
+ next[boxId] = true;
292
+ email.mailboxIds = next;
293
+ }
294
+ else if (path.startsWith('keywords/')) {
295
+ const keyword = unpointer(path.slice('keywords/'.length));
296
+ const next = { ...email.keywords };
297
+ if (value === null)
298
+ delete next[keyword];
299
+ else
300
+ next[keyword] = true;
301
+ email.keywords = next;
302
+ }
303
+ else if (path === 'keywords') {
304
+ email.keywords = { ...value };
305
+ }
306
+ else {
307
+ notUpdated[id] = { type: 'invalidPatch', description: `cannot patch ${path}` };
308
+ }
309
+ }
310
+ updated[id] = null;
311
+ }
312
+ return {
313
+ accountId: ACCOUNT,
314
+ oldState: '1',
315
+ newState: '2',
316
+ created,
317
+ notCreated: null,
318
+ updated,
319
+ notUpdated: Object.keys(notUpdated).length > 0 ? notUpdated : null,
320
+ };
266
321
  }
267
322
  case 'EmailSubmission/set': {
268
323
  const create = (args.create ?? {});
@@ -376,6 +431,11 @@ export class FakeMailbox {
376
431
  return out;
377
432
  }
378
433
  }
434
+ /** RFC 6901 §3 in reverse: one patch-key segment, unescaped. A mailbox id is
435
+ * the server's to choose, so a key built from one may carry `~0` or `~1`. */
436
+ function unpointer(segment) {
437
+ return segment.replaceAll('~1', '/').replaceAll('~0', '~');
438
+ }
379
439
  /** RFC 8620 §3.7's pointer, with the `*` that maps over a list. */
380
440
  function pointer(value, path) {
381
441
  let current = value;
@@ -1,11 +1,2 @@
1
1
  import type { SeedThread } from './mailbox.ts';
2
- /**
3
- * A plausible inbox.
4
- *
5
- * Chosen to put every state a list view has to draw on the screen at once:
6
- * unread and read, a thread with several messages, one with an attachment,
7
- * one flagged, one from a machine with no display name, and one subject long
8
- * enough to need truncating. A sample that is five tidy one-line messages
9
- * demonstrates a component that has never met mail.
10
- */
11
2
  export declare const SAMPLE: SeedThread[];
@@ -2,11 +2,26 @@
2
2
  * A plausible inbox.
3
3
  *
4
4
  * Chosen to put every state a list view has to draw on the screen at once:
5
- * unread and read, a thread with several messages, one with an attachment,
6
- * one flagged, one from a machine with no display name, and one subject long
7
- * enough to need truncating. A sample that is five tidy one-line messages
8
- * demonstrates a component that has never met mail.
5
+ * unread and read, a thread with several messages, one with several *unread*
6
+ * messages, one with an attachment, several with attachments of different
7
+ * kinds, one flagged, one from a machine with no display name, one with no
8
+ * subject at all, one whose sender has no display name, one subject long
9
+ * enough to need truncating and one preview that is a single word. A sample
10
+ * that is five tidy one-line messages demonstrates a component that has never
11
+ * met mail.
12
+ *
13
+ * **It is long on purpose.** A list of four rows cannot show whether a row is
14
+ * the right height, whether the date groups earn their space, whether the
15
+ * scroll region behaves or whether the pager is reachable. Sixteen
16
+ * conversations spread over a fortnight can, and that is the shortest sample
17
+ * that can: relative time collapses at an hour, a day, a week and a year, and
18
+ * a fixture with nothing older than yesterday exercises one of the four.
19
+ *
20
+ * Times are minutes before the fake's fixed "now", so every screenshot of
21
+ * this is the same screenshot.
9
22
  */
23
+ const HOUR = 60;
24
+ const DAY = 24 * HOUR;
10
25
  export const SAMPLE = [
11
26
  {
12
27
  mailbox: 'mb-inbox',
@@ -69,6 +84,187 @@ export const SAMPLE = [
69
84
  },
70
85
  ],
71
86
  },
87
+ /* Two people going back and forth, none of it read: the case where the row
88
+ has to say "4" and the mailbox count has to say one, not four. */
89
+ {
90
+ mailbox: 'mb-inbox',
91
+ messages: [
92
+ {
93
+ from: { name: 'Mary Somerville', email: 'mary@example.org' },
94
+ subject: 'The Difference Engine chapter',
95
+ text: 'I have marked up the proofs. Three passages need a decision from you before Friday.',
96
+ agoMinutes: 5 * HOUR,
97
+ },
98
+ {
99
+ from: { name: 'Mary Somerville', email: 'mary@example.org' },
100
+ subject: 'Re: The Difference Engine chapter',
101
+ text: 'Ignore the second passage — I have resolved it myself.',
102
+ agoMinutes: 4 * HOUR,
103
+ },
104
+ {
105
+ from: { name: 'Charles Babbage', email: 'charles@example.com' },
106
+ subject: 'Re: The Difference Engine chapter',
107
+ text: 'The third passage is the one that matters. I would rather we said nothing than said it vaguely.',
108
+ agoMinutes: 3 * HOUR,
109
+ },
110
+ {
111
+ from: { name: 'Mary Somerville', email: 'mary@example.org' },
112
+ subject: 'Re: The Difference Engine chapter',
113
+ text: 'Agreed. Ada, you have the casting vote.',
114
+ agoMinutes: 2 * HOUR,
115
+ },
116
+ ],
117
+ },
118
+ /* A message with no subject, which is a real thing that arrives and a real
119
+ way to make a list view draw an empty line. */
120
+ {
121
+ mailbox: 'mb-inbox',
122
+ messages: [
123
+ {
124
+ from: { name: 'Sophie Germain', email: 'sophie@example.fr' },
125
+ subject: '',
126
+ text: 'Yes.',
127
+ agoMinutes: 7 * HOUR,
128
+ seen: true,
129
+ },
130
+ ],
131
+ },
132
+ {
133
+ mailbox: 'mb-inbox',
134
+ messages: [
135
+ {
136
+ from: { name: 'The Royal Society', email: 'events@royalsociety.example.org' },
137
+ subject: 'Invitation: demonstration of the analytical engine, 14 October',
138
+ text: 'You are invited to a demonstration. Papers are attached. Please reply with numbers by the end of the month.',
139
+ agoMinutes: 9 * HOUR,
140
+ attachments: [
141
+ { name: 'programme.pdf', type: 'application/pdf', size: 214_882 },
142
+ { name: 'directions.png', type: 'image/png', size: 48_120 },
143
+ ],
144
+ },
145
+ ],
146
+ },
147
+ {
148
+ mailbox: 'mb-inbox',
149
+ messages: [
150
+ {
151
+ from: { name: 'Augustus De Morgan', email: 'augustus@example.ac.uk' },
152
+ subject: 'A correction, and an apology for the correction',
153
+ text: 'The recurrence in your note 7 is right and my objection was not. I have written to the editor withdrawing it.',
154
+ agoMinutes: 26 * HOUR,
155
+ seen: true,
156
+ },
157
+ {
158
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
159
+ subject: 'Re: A correction, and an apology for the correction',
160
+ text: 'No apology needed. The objection made the note better.',
161
+ agoMinutes: 25 * HOUR,
162
+ seen: true,
163
+ },
164
+ ],
165
+ },
166
+ /* Only an HTML body, which the reading pane refuses to render and says so.
167
+ Its own row so the state is reachable from the list rather than only
168
+ from a unit test. */
169
+ {
170
+ mailbox: 'mb-inbox',
171
+ messages: [
172
+ {
173
+ from: { email: 'campaigns@marketing.example.com' },
174
+ subject: 'Your weekly digest, beautifully formatted',
175
+ text: '',
176
+ html: '<html><body><h1>This week</h1><p>Rendered nowhere, on purpose.</p></body></html>',
177
+ agoMinutes: 30 * HOUR,
178
+ },
179
+ ],
180
+ },
181
+ {
182
+ mailbox: 'mb-inbox',
183
+ messages: [
184
+ {
185
+ from: { name: 'William King', email: 'william@example.com' },
186
+ subject: 'Ockham Park, the weekend',
187
+ text: 'The house is free from Friday. Bring the tables if you must, but not the mill.',
188
+ agoMinutes: 2 * DAY,
189
+ seen: true,
190
+ },
191
+ ],
192
+ },
193
+ {
194
+ mailbox: 'mb-inbox',
195
+ messages: [
196
+ {
197
+ from: { name: 'Michael Faraday', email: 'michael@example.org' },
198
+ subject: 'Re: your question about the coil',
199
+ text: 'It is the rate of change, not the field. I have drawn it out, which is quicker than the sentence.',
200
+ agoMinutes: 3 * DAY,
201
+ seen: true,
202
+ attachments: [{ name: 'coil.svg', type: 'image/svg+xml', size: 4_902 }],
203
+ },
204
+ ],
205
+ },
206
+ {
207
+ mailbox: 'mb-inbox',
208
+ messages: [
209
+ {
210
+ from: { email: 'security-noreply@accounts.example.net' },
211
+ subject: 'New sign-in from an unrecognised device',
212
+ text: 'A new sign-in was recorded. If this was you, no action is needed.',
213
+ agoMinutes: 4 * DAY,
214
+ seen: true,
215
+ },
216
+ ],
217
+ },
218
+ {
219
+ mailbox: 'mb-inbox',
220
+ messages: [
221
+ {
222
+ from: { name: 'Anne Isabella Byron', email: 'annabella@example.com' },
223
+ subject: 'Sunday',
224
+ text: 'Come at four. There is nothing to discuss and I would like to discuss it at length.',
225
+ agoMinutes: 6 * DAY,
226
+ seen: true,
227
+ flagged: true,
228
+ },
229
+ ],
230
+ },
231
+ {
232
+ mailbox: 'mb-inbox',
233
+ messages: [
234
+ {
235
+ from: { name: 'Joseph-Marie Jacquard', email: 'joseph@example.fr' },
236
+ subject: 'On the punched card mechanism, its failure modes in humid weather, and what I propose to do about the third of them before the demonstration',
237
+ text: 'The cards swell. Everything else in the list follows from the cards swelling, so I have started there.',
238
+ agoMinutes: 9 * DAY,
239
+ seen: true,
240
+ },
241
+ ],
242
+ },
243
+ {
244
+ mailbox: 'mb-inbox',
245
+ messages: [
246
+ {
247
+ from: { name: 'Charles Wheatstone', email: 'charles.w@example.org' },
248
+ subject: 'Telegraph trial — results',
249
+ text: 'Twelve miles, no repeater, legible throughout. The full log is attached.',
250
+ agoMinutes: 14 * DAY,
251
+ seen: true,
252
+ attachments: [{ name: 'trial-log.csv', type: 'text/csv', size: 11_204 }],
253
+ },
254
+ ],
255
+ },
256
+ {
257
+ mailbox: 'mb-inbox',
258
+ messages: [
259
+ {
260
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
261
+ subject: 'Notes to myself: the Bernoulli table',
262
+ text: 'Start from the recurrence, not the closed form. The closed form is where the last three attempts went wrong.',
263
+ agoMinutes: 400 * DAY,
264
+ seen: true,
265
+ },
266
+ ],
267
+ },
72
268
  {
73
269
  mailbox: 'mb-sent',
74
270
  messages: [
@@ -82,4 +278,28 @@ export const SAMPLE = [
82
278
  },
83
279
  ],
84
280
  },
281
+ {
282
+ mailbox: 'mb-sent',
283
+ messages: [
284
+ {
285
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
286
+ to: [{ name: 'Mary Somerville', email: 'mary@example.org' }],
287
+ subject: 'Re: The Difference Engine chapter',
288
+ text: 'Give me until tomorrow on the third passage.',
289
+ agoMinutes: 90,
290
+ seen: true,
291
+ },
292
+ ],
293
+ },
294
+ {
295
+ mailbox: 'mb-junk',
296
+ messages: [
297
+ {
298
+ from: { email: 'winner@lottery.example' },
299
+ subject: 'YOU HAVE BEEN SELECTED',
300
+ text: 'Reply with your bank details to claim your prize.',
301
+ agoMinutes: 8 * HOUR,
302
+ },
303
+ ],
304
+ },
85
305
  ];
@@ -21,6 +21,10 @@ export { attribution, formatAddress, forwardDraft, forwardIntroduction, forwardS
21
21
  export type { ForwardOptions, ReplyOptions } from './drafts.ts';
22
22
  export { draftToEmail, send, sentPatch } from './submit.ts';
23
23
  export type { SendOptions } from './submit.ts';
24
+ export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from './mutate.ts';
25
+ export type { MoveOptions } from './mutate.ts';
26
+ export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from './download.ts';
27
+ export type { DownloadOptions } from './download.ts';
24
28
  export { push } from './push.ts';
25
29
  export type { PushHandle, PushOptions } from './push.ts';
26
30
  export { expandTemplate } from './uri.ts';
@@ -13,5 +13,7 @@ export { search } from "./search.js";
13
13
  export { identities } from "./identities.js";
14
14
  export { attribution, formatAddress, forwardDraft, forwardIntroduction, forwardSubject, quoteText, replyDraft, replySubject, } from "./drafts.js";
15
15
  export { draftToEmail, send, sentPatch } from "./submit.js";
16
+ export { moveThread, moveToRole, removeFromMailbox, setFlagged, setRead, } from "./mutate.js";
17
+ export { DEFAULT_MAX_DOWNLOAD_BYTES, downloadAttachment } from "./download.js";
16
18
  export { push } from "./push.js";
17
19
  export { expandTemplate } from "./uri.js";