@wtfalch/email 0.1.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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/dist/mailbox/client.d.ts +85 -0
  3. package/dist/mailbox/client.js +201 -0
  4. package/dist/mailbox/drafts.d.ts +52 -0
  5. package/dist/mailbox/drafts.js +134 -0
  6. package/dist/mailbox/errors.d.ts +45 -0
  7. package/dist/mailbox/errors.js +88 -0
  8. package/dist/mailbox/fake/index.d.ts +34 -0
  9. package/dist/mailbox/fake/index.js +85 -0
  10. package/dist/mailbox/fake/mailbox.d.ts +65 -0
  11. package/dist/mailbox/fake/mailbox.js +402 -0
  12. package/dist/mailbox/fake/sample.d.ts +11 -0
  13. package/dist/mailbox/fake/sample.js +85 -0
  14. package/dist/mailbox/identities.d.ts +4 -0
  15. package/dist/mailbox/identities.js +18 -0
  16. package/dist/mailbox/index.d.ts +27 -0
  17. package/dist/mailbox/index.js +17 -0
  18. package/dist/mailbox/mail.css +451 -0
  19. package/dist/mailbox/mailboxes.d.ts +33 -0
  20. package/dist/mailbox/mailboxes.js +107 -0
  21. package/dist/mailbox/push.d.ts +40 -0
  22. package/dist/mailbox/push.js +127 -0
  23. package/dist/mailbox/react/Composer.d.ts +37 -0
  24. package/dist/mailbox/react/Composer.js +64 -0
  25. package/dist/mailbox/react/Mail.d.ts +8 -0
  26. package/dist/mailbox/react/Mail.js +149 -0
  27. package/dist/mailbox/react/MailboxTree.d.ts +14 -0
  28. package/dist/mailbox/react/MailboxTree.js +52 -0
  29. package/dist/mailbox/react/ThreadList.d.ts +37 -0
  30. package/dist/mailbox/react/ThreadList.js +41 -0
  31. package/dist/mailbox/react/ThreadView.d.ts +33 -0
  32. package/dist/mailbox/react/ThreadView.js +80 -0
  33. package/dist/mailbox/react/context.d.ts +11 -0
  34. package/dist/mailbox/react/context.js +28 -0
  35. package/dist/mailbox/react/hooks.d.ts +46 -0
  36. package/dist/mailbox/react/hooks.js +127 -0
  37. package/dist/mailbox/react/index.d.ts +24 -0
  38. package/dist/mailbox/react/index.js +18 -0
  39. package/dist/mailbox/search.d.ts +20 -0
  40. package/dist/mailbox/search.js +18 -0
  41. package/dist/mailbox/submit.d.ts +35 -0
  42. package/dist/mailbox/submit.js +150 -0
  43. package/dist/mailbox/thread.d.ts +61 -0
  44. package/dist/mailbox/thread.js +153 -0
  45. package/dist/mailbox/threads.d.ts +44 -0
  46. package/dist/mailbox/threads.js +156 -0
  47. package/dist/mailbox/types.d.ts +233 -0
  48. package/dist/mailbox/types.js +8 -0
  49. package/dist/mailbox/uri.d.ts +17 -0
  50. package/dist/mailbox/uri.js +26 -0
  51. package/dist/postmaster/apply.d.ts +62 -0
  52. package/dist/postmaster/apply.js +192 -0
  53. package/dist/postmaster/client.d.ts +127 -0
  54. package/dist/postmaster/client.js +235 -0
  55. package/dist/postmaster/index.d.ts +33 -0
  56. package/dist/postmaster/index.js +33 -0
  57. package/dist/postmaster/instance.d.ts +37 -0
  58. package/dist/postmaster/instance.js +21 -0
  59. package/dist/postmaster/load.d.ts +12 -0
  60. package/dist/postmaster/load.js +34 -0
  61. package/dist/postmaster/mailboxes.d.ts +23 -0
  62. package/dist/postmaster/mailboxes.js +35 -0
  63. package/dist/postmaster/objects.d.ts +47 -0
  64. package/dist/postmaster/objects.js +167 -0
  65. package/dist/postmaster/overview.d.ts +177 -0
  66. package/dist/postmaster/overview.js +112 -0
  67. package/dist/postmaster/react/controls.d.ts +18 -0
  68. package/dist/postmaster/react/controls.js +71 -0
  69. package/dist/postmaster/react/index.d.ts +13 -0
  70. package/dist/postmaster/react/index.js +12 -0
  71. package/dist/postmaster/react/panel.d.ts +42 -0
  72. package/dist/postmaster/react/panel.js +104 -0
  73. package/dist/postmaster/react/types.d.ts +10 -0
  74. package/dist/postmaster/react/types.js +1 -0
  75. package/dist/postmaster/writes.d.ts +62 -0
  76. package/dist/postmaster/writes.js +131 -0
  77. package/package.json +91 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A mailbox in memory, for stories, for a development build with no server,
3
+ * and for tests that want to exercise this package rather than a stub.
4
+ *
5
+ * ```ts
6
+ * import { fakeMail } from '@wtfalch/mail/fake'
7
+ *
8
+ * const { client, mailbox, dispose } = fakeMail()
9
+ * const tree = await mailboxes(client) // the real function
10
+ * ```
11
+ */
12
+ import { MailClient } from "../client.js";
13
+ import { FakeMailbox } from "./mailbox.js";
14
+ import { SAMPLE } from "./sample.js";
15
+ export { FakeMailbox };
16
+ export { SAMPLE };
17
+ const ORIGIN = 'https://email.example.com';
18
+ /**
19
+ * The interception, and why it is here rather than a `fetch` option on
20
+ * `MailClient`.
21
+ *
22
+ * `jmap-jam` calls the bare global `fetch`, and takes no way to pass another
23
+ * one. A `fetch` option would therefore have to swap the global around each
24
+ * call and put it back, which is wrong under any overlap: two calls in flight
25
+ * save each other's replacement as "the original" and one of them restores
26
+ * the fake permanently.
27
+ *
28
+ * So the global is replaced once, by a function that **delegates**: anything
29
+ * not addressed to the fake's origin goes to the real `fetch` untouched.
30
+ * Nothing else in the application changes behaviour, which is the property
31
+ * that makes replacing a global acceptable at all. A count keeps several
32
+ * fakes from uninstalling each other.
33
+ */
34
+ let installed = null;
35
+ let live = 0;
36
+ function install(route) {
37
+ live += 1;
38
+ if (installed)
39
+ return;
40
+ const original = globalThis.fetch;
41
+ installed = original;
42
+ globalThis.fetch = ((input, init) => {
43
+ const answered = route(input, init);
44
+ return answered ?? original(input, init);
45
+ });
46
+ }
47
+ function uninstall() {
48
+ live -= 1;
49
+ if (live > 0 || !installed)
50
+ return;
51
+ globalThis.fetch = installed;
52
+ installed = null;
53
+ }
54
+ /**
55
+ * A client and the mailbox it talks to.
56
+ *
57
+ * Seeded with `SAMPLE` unless told otherwise, because a fake with an empty
58
+ * inbox demonstrates the empty state and nothing else.
59
+ */
60
+ export function fakeMail(options = {}) {
61
+ const mailbox = new FakeMailbox({ threads: SAMPLE, ...options });
62
+ install((input, init) => {
63
+ const url = String(typeof input === 'object' && input !== null && 'url' in input
64
+ ? input.url
65
+ : input);
66
+ if (!url.startsWith(ORIGIN))
67
+ return null;
68
+ return mailbox.fetch(url, init);
69
+ });
70
+ const client = new MailClient({
71
+ sessionUrl: `${ORIGIN}/.well-known/jmap`,
72
+ bearerToken: 'fake',
73
+ });
74
+ let disposed = false;
75
+ return {
76
+ client,
77
+ mailbox,
78
+ dispose: () => {
79
+ if (disposed)
80
+ return;
81
+ disposed = true;
82
+ uninstall();
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A mailbox in memory, and the JMAP that answers questions about it.
3
+ *
4
+ * **A fake transport, not a fake API.** The obvious shortcut is to stub the
5
+ * exported functions -- `mailboxes()` returns three objects, `listThreads()`
6
+ * returns five rows -- and it is the wrong one: every story then exercises the
7
+ * stub, and the four-call chain, the result references, the body-value
8
+ * resolution and the null-handling that make up most of this package are
9
+ * never run. This answers JMAP instead, so a component drawn against the fake
10
+ * is drawn through exactly the code that will face a real server.
11
+ *
12
+ * It implements only the methods this package sends. Anything else answers
13
+ * `unknownMethod`, which is what a real server does and what the error path
14
+ * expects.
15
+ */
16
+ import type { EmailAddress } from '../types.ts';
17
+ export type SeedMessage = {
18
+ from: EmailAddress;
19
+ to?: readonly EmailAddress[];
20
+ subject: string;
21
+ text: string;
22
+ html?: string;
23
+ /** Minutes before "now". Later numbers are older. */
24
+ agoMinutes: number;
25
+ seen?: boolean;
26
+ flagged?: boolean;
27
+ attachments?: {
28
+ name: string;
29
+ type: string;
30
+ size: number;
31
+ }[];
32
+ };
33
+ export type SeedThread = {
34
+ mailbox: string;
35
+ messages: SeedMessage[];
36
+ };
37
+ export type FakeOptions = {
38
+ /** The signed-in person. */
39
+ me?: EmailAddress;
40
+ threads?: SeedThread[];
41
+ /** Fixed "now", so a story's timestamps do not move between runs. */
42
+ now?: Date;
43
+ /** Milliseconds of delay on every request, for looking at a loading state. */
44
+ latency?: number;
45
+ };
46
+ export declare class FakeMailbox {
47
+ #private;
48
+ readonly me: EmailAddress;
49
+ /** Everything `EmailSubmission/set` accepted, for a test that asserts a
50
+ * message was actually sent rather than only composed. */
51
+ readonly submissions: {
52
+ emailId: string;
53
+ identityId: string;
54
+ }[];
55
+ constructor(options?: FakeOptions);
56
+ /** Put a thread in the mailbox. Returns its id. */
57
+ add(seed: SeedThread): string;
58
+ /** The session document, as Stalwart writes it. */
59
+ session(origin?: string): Record<string, unknown>;
60
+ /** Answer one method call. Exported for the transport, and for a test that
61
+ * wants to assert on a single call's arguments. */
62
+ call(method: string, args: Record<string, unknown>): Record<string, unknown>;
63
+ /** A `fetch` that answers this mailbox. Hand it to `MailClient`. */
64
+ fetch: (input: unknown, init?: RequestInit) => Promise<Response>;
65
+ }
@@ -0,0 +1,402 @@
1
+ /**
2
+ * A mailbox in memory, and the JMAP that answers questions about it.
3
+ *
4
+ * **A fake transport, not a fake API.** The obvious shortcut is to stub the
5
+ * exported functions -- `mailboxes()` returns three objects, `listThreads()`
6
+ * returns five rows -- and it is the wrong one: every story then exercises the
7
+ * stub, and the four-call chain, the result references, the body-value
8
+ * resolution and the null-handling that make up most of this package are
9
+ * never run. This answers JMAP instead, so a component drawn against the fake
10
+ * is drawn through exactly the code that will face a real server.
11
+ *
12
+ * It implements only the methods this package sends. Anything else answers
13
+ * `unknownMethod`, which is what a real server does and what the error path
14
+ * expects.
15
+ */
16
+ const ACCOUNT = 'fake-account';
17
+ /** Stalwart's five, with its names and Stalwart's uniform sortOrder of 0 --
18
+ * which is what makes a real tree come out alphabetical. */
19
+ const MAILBOXES = [
20
+ { id: 'mb-inbox', name: 'Inbox', role: 'inbox' },
21
+ { id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
22
+ { id: 'mb-sent', name: 'Sent Items', role: 'sent' },
23
+ { id: 'mb-junk', name: 'Junk Mail', role: 'junk' },
24
+ { id: 'mb-trash', name: 'Deleted Items', role: 'trash' },
25
+ ];
26
+ export class FakeMailbox {
27
+ me;
28
+ #now;
29
+ #latency;
30
+ #emails = [];
31
+ #threads = new Map();
32
+ #counter = 0;
33
+ /** Everything `EmailSubmission/set` accepted, for a test that asserts a
34
+ * message was actually sent rather than only composed. */
35
+ submissions = [];
36
+ constructor(options = {}) {
37
+ this.me = options.me ?? { name: 'Ada Lovelace', email: 'ada@example.com' };
38
+ this.#now = options.now ?? new Date('2026-09-07T12:00:00Z');
39
+ this.#latency = options.latency ?? 0;
40
+ for (const seed of options.threads ?? [])
41
+ this.add(seed);
42
+ }
43
+ /** Put a thread in the mailbox. Returns its id. */
44
+ add(seed) {
45
+ const threadId = `t${++this.#counter}`;
46
+ const ids = [];
47
+ for (const message of seed.messages) {
48
+ const id = `e${++this.#counter}`;
49
+ const at = new Date(this.#now.getTime() - seed.messages.indexOf(message) * 0);
50
+ const receivedAt = new Date(this.#now.getTime() - message.agoMinutes * 60_000).toISOString();
51
+ const attachments = (message.attachments ?? []).map((file, index) => ({
52
+ partId: `a${index}`,
53
+ blobId: `blob-${id}-${index}`,
54
+ size: file.size,
55
+ name: file.name,
56
+ type: file.type,
57
+ disposition: 'attachment',
58
+ cid: null,
59
+ }));
60
+ this.#emails.push({
61
+ id,
62
+ blobId: `blob-${id}`,
63
+ threadId,
64
+ mailboxIds: { [seed.mailbox]: true },
65
+ keywords: {
66
+ ...(message.seen ? { $seen: true } : {}),
67
+ ...(message.flagged ? { $flagged: true } : {}),
68
+ },
69
+ size: message.text.length + 400,
70
+ receivedAt,
71
+ sentAt: receivedAt,
72
+ messageId: [`${id}@example.com`],
73
+ inReplyTo: null,
74
+ references: null,
75
+ from: [message.from],
76
+ to: message.to ?? [this.me],
77
+ cc: null,
78
+ bcc: null,
79
+ replyTo: null,
80
+ subject: message.subject,
81
+ preview: message.text.slice(0, 80),
82
+ hasAttachment: attachments.length > 0,
83
+ textBody: [
84
+ {
85
+ partId: 't',
86
+ blobId: `blob-${id}-t`,
87
+ size: message.text.length,
88
+ name: null,
89
+ type: 'text/plain',
90
+ charset: 'UTF-8',
91
+ disposition: null,
92
+ cid: null,
93
+ },
94
+ ],
95
+ htmlBody: message.html
96
+ ? [
97
+ {
98
+ partId: 'h',
99
+ blobId: `blob-${id}-h`,
100
+ size: message.html.length,
101
+ name: null,
102
+ type: 'text/html',
103
+ charset: 'UTF-8',
104
+ disposition: null,
105
+ cid: null,
106
+ },
107
+ ]
108
+ : [],
109
+ attachments,
110
+ bodyValues: {
111
+ t: { value: message.text, isEncodingProblem: false, isTruncated: false },
112
+ ...(message.html
113
+ ? { h: { value: message.html, isEncodingProblem: false, isTruncated: false } }
114
+ : {}),
115
+ },
116
+ _at: at,
117
+ });
118
+ ids.push(id);
119
+ }
120
+ this.#threads.set(threadId, ids);
121
+ return threadId;
122
+ }
123
+ #mailboxes() {
124
+ return MAILBOXES.map((box) => {
125
+ const inBox = this.#emails.filter((email) => email.mailboxIds[box.id]);
126
+ const unread = inBox.filter((email) => !email.keywords.$seen);
127
+ const threads = new Set(inBox.map((email) => email.threadId));
128
+ const unreadThreads = new Set(unread.map((email) => email.threadId));
129
+ return {
130
+ id: box.id,
131
+ name: box.name,
132
+ parentId: null,
133
+ role: box.role,
134
+ sortOrder: 0,
135
+ totalEmails: inBox.length,
136
+ unreadEmails: unread.length,
137
+ totalThreads: threads.size,
138
+ unreadThreads: unreadThreads.size,
139
+ isSubscribed: true,
140
+ };
141
+ });
142
+ }
143
+ /** The session document, as Stalwart writes it. */
144
+ session(origin = 'https://email.example.com') {
145
+ return {
146
+ capabilities: {
147
+ 'urn:ietf:params:jmap:core': {},
148
+ 'urn:ietf:params:jmap:mail': {},
149
+ 'urn:ietf:params:jmap:submission': {},
150
+ },
151
+ accounts: {
152
+ [ACCOUNT]: {
153
+ name: this.me.email,
154
+ isPersonal: true,
155
+ isReadOnly: false,
156
+ accountCapabilities: {
157
+ 'urn:ietf:params:jmap:mail': {},
158
+ 'urn:ietf:params:jmap:submission': {},
159
+ },
160
+ },
161
+ },
162
+ primaryAccounts: {
163
+ 'urn:ietf:params:jmap:mail': ACCOUNT,
164
+ 'urn:ietf:params:jmap:submission': ACCOUNT,
165
+ },
166
+ username: this.me.email,
167
+ apiUrl: `${origin}/jmap/`,
168
+ downloadUrl: `${origin}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
169
+ uploadUrl: `${origin}/jmap/upload/{accountId}/`,
170
+ eventSourceUrl: `${origin}/jmap/eventsource/?types={types}&closeafter={closeafter}&ping={ping}`,
171
+ state: 'fake-session',
172
+ };
173
+ }
174
+ /** Answer one method call. Exported for the transport, and for a test that
175
+ * wants to assert on a single call's arguments. */
176
+ call(method, args) {
177
+ switch (method) {
178
+ case 'Mailbox/get':
179
+ return { accountId: ACCOUNT, state: 'm1', list: this.#mailboxes(), notFound: [] };
180
+ case 'Identity/get':
181
+ return {
182
+ accountId: ACCOUNT,
183
+ state: 'i1',
184
+ list: [
185
+ {
186
+ id: 'identity-1',
187
+ name: this.me.name ?? '',
188
+ email: this.me.email,
189
+ replyTo: null,
190
+ bcc: null,
191
+ textSignature: '',
192
+ htmlSignature: '',
193
+ mayDelete: false,
194
+ },
195
+ ],
196
+ notFound: [],
197
+ };
198
+ case 'Email/query': {
199
+ const filter = (args.filter ?? {});
200
+ let matching = this.#emails.filter((email) => this.#matches(email, filter));
201
+ matching.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1));
202
+ if (args.collapseThreads) {
203
+ const seen = new Set();
204
+ const collapsed = [];
205
+ for (const email of matching) {
206
+ if (seen.has(email.threadId))
207
+ continue;
208
+ seen.add(email.threadId);
209
+ collapsed.push(email);
210
+ }
211
+ matching = collapsed;
212
+ }
213
+ const position = Number(args.position ?? 0);
214
+ const limit = args.limit === undefined ? matching.length : Number(args.limit);
215
+ return {
216
+ accountId: ACCOUNT,
217
+ queryState: 'q1',
218
+ canCalculateChanges: false,
219
+ position,
220
+ ids: matching.slice(position, position + limit).map((email) => email.id),
221
+ ...(args.calculateTotal ? { total: matching.length } : {}),
222
+ };
223
+ }
224
+ case 'Email/get': {
225
+ const ids = (args.ids ?? []);
226
+ const properties = args.properties;
227
+ const list = ids
228
+ .map((id) => this.#emails.find((email) => email.id === id))
229
+ .filter((email) => email !== undefined)
230
+ .map((email) => this.#project(email, properties, Boolean(args.fetchTextBodyValues)));
231
+ return {
232
+ accountId: ACCOUNT,
233
+ state: 'e1',
234
+ list,
235
+ notFound: ids.filter((id) => !this.#emails.some((email) => email.id === id)),
236
+ };
237
+ }
238
+ case 'Thread/get': {
239
+ const ids = (args.ids ?? []);
240
+ return {
241
+ accountId: ACCOUNT,
242
+ state: 'th1',
243
+ list: ids
244
+ .filter((id) => this.#threads.has(id))
245
+ .map((id) => ({ id, emailIds: this.#threads.get(id) ?? [] })),
246
+ notFound: ids.filter((id) => !this.#threads.has(id)),
247
+ };
248
+ }
249
+ case 'Email/set': {
250
+ const create = (args.create ?? {});
251
+ const created = {};
252
+ for (const [key, draft] of Object.entries(create)) {
253
+ const id = `e${++this.#counter}`;
254
+ const threadId = `t${++this.#counter}`;
255
+ this.#emails.push({
256
+ ...draft,
257
+ id,
258
+ blobId: `blob-${id}`,
259
+ threadId,
260
+ receivedAt: this.#now.toISOString(),
261
+ });
262
+ this.#threads.set(threadId, [id]);
263
+ created[key] = { id, blobId: `blob-${id}`, threadId, size: 512 };
264
+ }
265
+ return { accountId: ACCOUNT, oldState: '1', newState: '2', created, notCreated: null };
266
+ }
267
+ case 'EmailSubmission/set': {
268
+ const create = (args.create ?? {});
269
+ const created = {};
270
+ for (const [key, submission] of Object.entries(create)) {
271
+ this.submissions.push(submission);
272
+ created[key] = { id: `sub-${this.submissions.length}`, undoStatus: 'final' };
273
+ // The move to Sent that `onSuccessUpdateEmail` asks for.
274
+ const email = this.#emails.find((one) => one.id === submission.emailId);
275
+ if (email) {
276
+ email.mailboxIds = { 'mb-sent': true };
277
+ email.keywords = { $seen: true };
278
+ }
279
+ }
280
+ return { accountId: ACCOUNT, oldState: '2', newState: '3', created, notCreated: null };
281
+ }
282
+ default:
283
+ return {
284
+ type: 'unknownMethod',
285
+ description: `the fake mailbox does not implement ${method}`,
286
+ };
287
+ }
288
+ }
289
+ #matches(email, filter) {
290
+ const mailboxIds = email.mailboxIds;
291
+ if (typeof filter.inMailbox === 'string' && !mailboxIds[filter.inMailbox])
292
+ return false;
293
+ if (Array.isArray(filter.inMailboxOtherThan)) {
294
+ const excluded = filter.inMailboxOtherThan;
295
+ if (Object.keys(mailboxIds).every((id) => excluded.includes(id)))
296
+ return false;
297
+ }
298
+ if (typeof filter.text === 'string') {
299
+ const haystack = [
300
+ email.subject,
301
+ email.bodyValues?.t?.value,
302
+ ...(email.from ?? []).map((a) => `${a.name ?? ''} ${a.email}`),
303
+ ]
304
+ .join(' ')
305
+ .toLowerCase();
306
+ if (!haystack.includes(filter.text.toLowerCase()))
307
+ return false;
308
+ }
309
+ if (typeof filter.from === 'string') {
310
+ const from = (email.from ?? []).map((a) => a.email).join(' ');
311
+ if (!from.toLowerCase().includes(filter.from.toLowerCase()))
312
+ return false;
313
+ }
314
+ if (filter.hasAttachment === true && !email.hasAttachment)
315
+ return false;
316
+ return true;
317
+ }
318
+ /** Return only what was asked for, the way a server does -- so a component
319
+ * reading a property the query never requested fails here as it would
320
+ * there. */
321
+ #project(email, properties, bodies) {
322
+ const { _at, bodyValues, ...rest } = email;
323
+ const base = { ...rest, ...(bodies ? { bodyValues } : {}) };
324
+ if (!properties)
325
+ return base;
326
+ const out = { id: email.id };
327
+ for (const key of properties)
328
+ if (key in base)
329
+ out[key] = base[key];
330
+ if (bodies)
331
+ out.bodyValues = base.bodyValues;
332
+ return out;
333
+ }
334
+ /** A `fetch` that answers this mailbox. Hand it to `MailClient`. */
335
+ fetch = async (input, init) => {
336
+ if (this.#latency > 0)
337
+ await new Promise((resolve) => setTimeout(resolve, this.#latency));
338
+ const url = String(input);
339
+ const json = (body) => new Response(JSON.stringify(body), {
340
+ status: 200,
341
+ headers: { 'Content-Type': 'application/json' },
342
+ });
343
+ if (!init || (init.method ?? 'GET') === 'GET') {
344
+ if (url.includes('/jmap/download/'))
345
+ return new Response('fake attachment bytes');
346
+ if (url.includes('/eventsource')) {
347
+ // An open stream that never sends: `push` stays connected and quiet.
348
+ return new Response(new ReadableStream(), {
349
+ headers: { 'Content-Type': 'text/event-stream' },
350
+ });
351
+ }
352
+ return json(this.session(new URL(url).origin));
353
+ }
354
+ const body = JSON.parse(String(init.body));
355
+ const responses = [];
356
+ for (const [method, args, id] of body.methodCalls) {
357
+ const resolved = this.#resolveRefs(args, responses);
358
+ const result = this.call(method, resolved);
359
+ responses.push([result.type === 'unknownMethod' ? 'error' : method, result, id]);
360
+ }
361
+ return json({ methodResponses: responses, sessionState: 'fake-session' });
362
+ };
363
+ /** Replace `#ids` result references with the values they point at, which is
364
+ * the whole reason a page of threads is one round trip. */
365
+ #resolveRefs(args, done) {
366
+ const out = {};
367
+ for (const [key, value] of Object.entries(args)) {
368
+ if (!key.startsWith('#')) {
369
+ out[key] = value;
370
+ continue;
371
+ }
372
+ const reference = value;
373
+ const source = done.find(([, , id]) => id === reference.resultOf)?.[1];
374
+ out[key.slice(1)] = source ? pointer(source, reference.path) : [];
375
+ }
376
+ return out;
377
+ }
378
+ }
379
+ /** RFC 8620 §3.7's pointer, with the `*` that maps over a list. */
380
+ function pointer(value, path) {
381
+ let current = value;
382
+ for (const rawSegment of path.split('/').filter(Boolean)) {
383
+ const segment = rawSegment.replaceAll('~1', '/').replaceAll('~0', '~');
384
+ if (segment === '*') {
385
+ if (!Array.isArray(current))
386
+ return [];
387
+ // The rest of the path applies to every item, and a list of lists is
388
+ // flattened -- which is what turns `/list/*/emailIds` into every id.
389
+ const rest = path.slice(path.indexOf('*') + 1);
390
+ const mapped = current.map((item) => pointer(item, rest));
391
+ return mapped.flat();
392
+ }
393
+ if (Array.isArray(current))
394
+ current = current[Number(segment)];
395
+ else if (current && typeof current === 'object') {
396
+ current = current[segment];
397
+ }
398
+ else
399
+ return undefined;
400
+ }
401
+ return current;
402
+ }
@@ -0,0 +1,11 @@
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
+ export declare const SAMPLE: SeedThread[];
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A plausible inbox.
3
+ *
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.
9
+ */
10
+ export const SAMPLE = [
11
+ {
12
+ mailbox: 'mb-inbox',
13
+ messages: [
14
+ {
15
+ from: { name: 'Charles Babbage', email: 'charles@example.com' },
16
+ subject: 'Engine schedule',
17
+ text: 'The mill is ready and the store is not. Can we push the demonstration to Thursday?',
18
+ agoMinutes: 12,
19
+ },
20
+ ],
21
+ },
22
+ {
23
+ mailbox: 'mb-inbox',
24
+ messages: [
25
+ {
26
+ from: { name: 'Luigi Menabrea', email: 'luigi@example.org' },
27
+ subject: 'Notes on the analytical engine, and a question about the notes',
28
+ text: 'I have finished the translation. The notes are now longer than the paper, which I suspect is the point.',
29
+ agoMinutes: 180,
30
+ seen: true,
31
+ },
32
+ {
33
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
34
+ subject: 'Re: Notes on the analytical engine, and a question about the notes',
35
+ text: 'Good. Keep them longer than the paper.',
36
+ agoMinutes: 90,
37
+ seen: true,
38
+ },
39
+ {
40
+ from: { name: 'Luigi Menabrea', email: 'luigi@example.org' },
41
+ subject: 'Re: Notes on the analytical engine, and a question about the notes',
42
+ text: 'Then I will add the table of Bernoulli numbers as well.',
43
+ agoMinutes: 45,
44
+ },
45
+ ],
46
+ },
47
+ {
48
+ mailbox: 'mb-inbox',
49
+ messages: [
50
+ {
51
+ from: { email: 'noreply@notifications.example.org' },
52
+ subject: 'Your monthly statement is ready',
53
+ text: 'Your statement for August is attached. This mailbox is not monitored.',
54
+ agoMinutes: 600,
55
+ seen: true,
56
+ attachments: [{ name: 'statement-august.pdf', type: 'application/pdf', size: 82_411 }],
57
+ },
58
+ ],
59
+ },
60
+ {
61
+ mailbox: 'mb-inbox',
62
+ messages: [
63
+ {
64
+ from: { name: "Grace O'Brien", email: 'grace@example.net' },
65
+ subject: 'Punch cards — reorder before Friday',
66
+ text: 'We are down to two boxes. I have put the order together; it needs your signature.',
67
+ agoMinutes: 1_500,
68
+ flagged: true,
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ mailbox: 'mb-sent',
74
+ messages: [
75
+ {
76
+ from: { name: 'Ada Lovelace', email: 'ada@example.com' },
77
+ to: [{ name: 'Charles Babbage', email: 'charles@example.com' }],
78
+ subject: 'Thursday works',
79
+ text: 'Thursday works. I will bring the tables.',
80
+ agoMinutes: 5,
81
+ seen: true,
82
+ },
83
+ ],
84
+ },
85
+ ];
@@ -0,0 +1,4 @@
1
+ import type { MailClient } from './client.ts';
2
+ import type { MailIdentity } from './types.ts';
3
+ /** Every address this account may send as. */
4
+ export declare function identities(client: MailClient): Promise<readonly MailIdentity[]>;
@@ -0,0 +1,18 @@
1
+ import { guard } from "./errors.js";
2
+ /** Every address this account may send as. */
3
+ export async function identities(client) {
4
+ const { jam, accountId } = await client.connect();
5
+ return guard('identities', async () => {
6
+ const [result] = await jam.api.Identity.get({ accountId });
7
+ return result.list.map((identity) => ({
8
+ id: identity.id,
9
+ name: identity.name ?? '',
10
+ email: identity.email,
11
+ replyTo: identity.replyTo ?? [],
12
+ bcc: identity.bcc ?? [],
13
+ textSignature: identity.textSignature ?? '',
14
+ htmlSignature: identity.htmlSignature ?? '',
15
+ mayDelete: identity.mayDelete ?? false,
16
+ }));
17
+ });
18
+ }