@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Tallis Falch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,85 @@
1
+ import { JamClient } from 'jmap-jam';
2
+ import type { MailSession } from './types.ts';
3
+ export declare const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail";
4
+ export declare const SUBMISSION_CAPABILITY = "urn:ietf:params:jmap:submission";
5
+ /** The parts of the JMAP session document this package uses. */
6
+ type SessionDocument = {
7
+ username: string;
8
+ apiUrl: string;
9
+ downloadUrl: string;
10
+ uploadUrl: string;
11
+ eventSourceUrl: string;
12
+ state: string;
13
+ accounts: Record<string, {
14
+ name: string;
15
+ isReadOnly: boolean;
16
+ accountCapabilities: Record<string, unknown>;
17
+ }>;
18
+ primaryAccounts: Record<string, string>;
19
+ };
20
+ export type MailClientOptions = {
21
+ /** The JMAP session URL, e.g. `https://email.example.com/.well-known/jmap`.
22
+ * Stalwart redirects that to `/jmap/session`. */
23
+ sessionUrl: string;
24
+ /** The access token sent as `Authorization: Bearer`. Under an OIDC
25
+ * directory this is the signed-in person's token from the issuer; for a
26
+ * mail client without OAuth it is an app password's basic credential,
27
+ * which is not a bearer token and needs `authorization` instead. */
28
+ bearerToken?: string;
29
+ /** A complete `Authorization` header value, when it is not a bearer token. */
30
+ authorization?: string;
31
+ /** Use a different account than the session's primary one. */
32
+ accountId?: string;
33
+ };
34
+ /**
35
+ * A connection to one JMAP account.
36
+ *
37
+ * Every other module in this package takes one of these. It owns the session
38
+ * document, the account id, and the `jmap-jam` client the wire calls go
39
+ * through; `jmap-jam` itself is not part of this package's public surface.
40
+ */
41
+ export declare class MailClient {
42
+ #private;
43
+ constructor(options: MailClientOptions);
44
+ /**
45
+ * Use a new token from here on. An access token expires long before a mail
46
+ * client is closed, so the app refreshes it and calls this; the next
47
+ * request re-reads the session document with it.
48
+ */
49
+ setToken(bearerToken: string): void;
50
+ setAuthorization(authorization: string): void;
51
+ /** @internal The wire client and the resolved session, connecting if needed. */
52
+ connect(): Promise<{
53
+ jam: JamClient;
54
+ doc: SessionDocument;
55
+ accountId: string;
56
+ }>;
57
+ /** The signed-in person and the account being read. */
58
+ session(): Promise<MailSession>;
59
+ accountId(): Promise<string>;
60
+ /**
61
+ * The URL that fetches a blob's bytes. It carries no credential, so the
62
+ * request needs the same `Authorization` header as the API.
63
+ */
64
+ downloadUrl(blob: {
65
+ blobId: string;
66
+ name: string;
67
+ type: string;
68
+ }): Promise<string>;
69
+ /** The `Authorization` header value every request carries, for a caller
70
+ * fetching a download URL itself. */
71
+ get authorization(): string;
72
+ /** Upload bytes and get back a blob id a draft can attach. */
73
+ upload(body: Blob | ArrayBuffer | Uint8Array | string, type?: string): Promise<{
74
+ blobId: string;
75
+ type: string;
76
+ size: number;
77
+ }>;
78
+ /** The event source URL for `push`, with its template expanded. */
79
+ eventSourceUrl(options: {
80
+ types: string;
81
+ ping: number;
82
+ closeAfter?: 'state' | 'no';
83
+ }): Promise<string>;
84
+ }
85
+ export {};
@@ -0,0 +1,201 @@
1
+ import { JamClient } from 'jmap-jam';
2
+ import { MailError, guard, toMailError } from "./errors.js";
3
+ import { expandTemplate } from "./uri.js";
4
+ export const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
5
+ export const SUBMISSION_CAPABILITY = 'urn:ietf:params:jmap:submission';
6
+ function looksLikeSession(value) {
7
+ return (typeof value === 'object' &&
8
+ value !== null &&
9
+ typeof value.apiUrl === 'string' &&
10
+ typeof value.accounts === 'object');
11
+ }
12
+ /**
13
+ * A connection to one JMAP account.
14
+ *
15
+ * Every other module in this package takes one of these. It owns the session
16
+ * document, the account id, and the `jmap-jam` client the wire calls go
17
+ * through; `jmap-jam` itself is not part of this package's public surface.
18
+ */
19
+ export class MailClient {
20
+ #sessionUrl;
21
+ #wantedAccountId;
22
+ #authorization;
23
+ #connection;
24
+ constructor(options) {
25
+ const { sessionUrl, bearerToken, authorization, accountId } = options;
26
+ if (!bearerToken && !authorization) {
27
+ throw new MailError('a MailClient needs either a bearerToken or an authorization header');
28
+ }
29
+ this.#sessionUrl = sessionUrl;
30
+ this.#authorization = authorization ?? `Bearer ${bearerToken}`;
31
+ this.#wantedAccountId = accountId;
32
+ }
33
+ /**
34
+ * Use a new token from here on. An access token expires long before a mail
35
+ * client is closed, so the app refreshes it and calls this; the next
36
+ * request re-reads the session document with it.
37
+ */
38
+ setToken(bearerToken) {
39
+ this.setAuthorization(`Bearer ${bearerToken}`);
40
+ }
41
+ setAuthorization(authorization) {
42
+ if (authorization === this.#authorization)
43
+ return;
44
+ this.#authorization = authorization;
45
+ this.#connection = undefined;
46
+ }
47
+ /** @internal The wire client and the resolved session, connecting if needed. */
48
+ async connect() {
49
+ this.#connection ??= this.#open();
50
+ try {
51
+ return await this.#connection;
52
+ }
53
+ catch (error) {
54
+ // A failed connection must not be cached, or a token refresh cannot
55
+ // recover: the next call would replay the same rejected promise.
56
+ this.#connection = undefined;
57
+ throw error;
58
+ }
59
+ }
60
+ async #open() {
61
+ const authorization = this.#authorization;
62
+ const isBearer = authorization.startsWith('Bearer ');
63
+ // `JamClient` can only build a `Bearer` header from the string it is
64
+ // given, so a credential that is not a bearer token (an app password,
65
+ // which Stalwart takes as Basic) needs the header replaced afterwards.
66
+ // The constructor has already fired its own session request by then, so
67
+ // that path reads the session here and discards what the constructor
68
+ // fetched; the bearer path, which is what the product uses, costs one
69
+ // request as usual.
70
+ const jam = new JamClient({
71
+ sessionUrl: this.#sessionUrl,
72
+ bearerToken: isBearer ? authorization.slice('Bearer '.length) : '',
73
+ });
74
+ let doc;
75
+ if (isBearer) {
76
+ let loaded;
77
+ try {
78
+ loaded = await jam.session;
79
+ }
80
+ catch (cause) {
81
+ // `JamClient.loadSession` calls `.json()` without checking the
82
+ // status, so a 401 with an HTML body arrives here as a parse error.
83
+ throw await this.#sessionFailure(authorization, cause);
84
+ }
85
+ if (!looksLikeSession(loaded))
86
+ throw await this.#sessionFailure(authorization, loaded);
87
+ doc = loaded;
88
+ }
89
+ else {
90
+ jam.authHeader = authorization;
91
+ doc = await this.#fetchSession(authorization);
92
+ jam.session = Promise.resolve(doc);
93
+ }
94
+ const accountId = this.#wantedAccountId ?? doc.primaryAccounts?.[MAIL_CAPABILITY];
95
+ if (!accountId) {
96
+ throw new MailError(`the session document names no primary account for ${MAIL_CAPABILITY}; the server may not offer JMAP Mail to this login`, { operation: 'client.connect' });
97
+ }
98
+ if (!doc.accounts[accountId]) {
99
+ throw new MailError(`the session document has no account ${accountId}`, {
100
+ operation: 'client.connect',
101
+ });
102
+ }
103
+ return { jam, doc, accountId };
104
+ }
105
+ /** Read the session document with the status checked, so a failure says
106
+ * what the server actually answered. */
107
+ async #fetchSession(authorization) {
108
+ let response;
109
+ try {
110
+ response = await fetch(this.#sessionUrl, {
111
+ headers: { Authorization: authorization, Accept: 'application/json' },
112
+ cache: 'no-cache',
113
+ });
114
+ }
115
+ catch (cause) {
116
+ throw new MailError(`could not reach ${this.#sessionUrl}`, {
117
+ operation: 'client.connect',
118
+ cause,
119
+ });
120
+ }
121
+ if (response.status === 401 || response.status === 403) {
122
+ throw new MailError(`the server rejected the credential for ${this.#sessionUrl} (${response.status})`, { status: response.status, operation: 'client.connect' });
123
+ }
124
+ const body = await response.text();
125
+ let parsed;
126
+ try {
127
+ parsed = JSON.parse(body);
128
+ }
129
+ catch {
130
+ parsed = undefined;
131
+ }
132
+ if (!looksLikeSession(parsed)) {
133
+ throw new MailError(`${this.#sessionUrl} did not answer with a JMAP session document (${response.status}): ${body.slice(0, 200)}`, { status: response.status, operation: 'client.connect' });
134
+ }
135
+ return parsed;
136
+ }
137
+ /** Turn a failed or unrecognisable session read into an error that says
138
+ * what happened, by asking again with the status checked. */
139
+ async #sessionFailure(authorization, cause) {
140
+ try {
141
+ await this.#fetchSession(authorization);
142
+ }
143
+ catch (error) {
144
+ return toMailError(error, 'client.connect');
145
+ }
146
+ // The second read succeeded, so the first was a transient failure.
147
+ return toMailError(cause, 'client.connect');
148
+ }
149
+ /** The signed-in person and the account being read. */
150
+ async session() {
151
+ const { doc, accountId } = await this.connect();
152
+ const account = doc.accounts[accountId];
153
+ return {
154
+ username: doc.username,
155
+ accountId,
156
+ accountName: account?.name ?? '',
157
+ isReadOnly: account?.isReadOnly ?? false,
158
+ maySubmit: Boolean(account?.accountCapabilities?.[SUBMISSION_CAPABILITY]),
159
+ };
160
+ }
161
+ async accountId() {
162
+ return (await this.connect()).accountId;
163
+ }
164
+ /**
165
+ * The URL that fetches a blob's bytes. It carries no credential, so the
166
+ * request needs the same `Authorization` header as the API.
167
+ */
168
+ async downloadUrl(blob) {
169
+ const { doc, accountId } = await this.connect();
170
+ return expandTemplate(doc.downloadUrl, {
171
+ accountId,
172
+ blobId: blob.blobId,
173
+ name: blob.name,
174
+ type: blob.type,
175
+ });
176
+ }
177
+ /** The `Authorization` header value every request carries, for a caller
178
+ * fetching a download URL itself. */
179
+ get authorization() {
180
+ return this.#authorization;
181
+ }
182
+ /** Upload bytes and get back a blob id a draft can attach. */
183
+ async upload(body, type = 'application/octet-stream') {
184
+ const { jam, accountId } = await this.connect();
185
+ return guard('client.upload', async () => {
186
+ const result = await jam.uploadBlob(accountId, body, {
187
+ headers: { Authorization: this.#authorization, 'Content-Type': type },
188
+ });
189
+ return { blobId: result.blobId, type: result.type, size: result.size };
190
+ });
191
+ }
192
+ /** The event source URL for `push`, with its template expanded. */
193
+ async eventSourceUrl(options) {
194
+ const { doc } = await this.connect();
195
+ return expandTemplate(doc.eventSourceUrl, {
196
+ types: options.types,
197
+ ping: String(options.ping),
198
+ closeafter: options.closeAfter ?? 'no',
199
+ });
200
+ }
201
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Building a reply or a forward from a message is pure: no client, no
3
+ * network. It is also where mail clients quietly get things wrong, so it
4
+ * lives on its own and is tested on its own.
5
+ */
6
+ import type { Draft, EmailAddress, MailIdentity, Message } from './types.ts';
7
+ /** `Re: ` once, however many times the subject already carries it. */
8
+ export declare function replySubject(subject: string): string;
9
+ /** `Fwd: ` once. A forward of a reply keeps the `Re:` in the middle, which is
10
+ * what the thread was actually called. */
11
+ export declare function forwardSubject(subject: string): string;
12
+ /** `Ada Lovelace <ada@example.com>`, or the bare address when there is no name. */
13
+ export declare function formatAddress(address: EmailAddress): string;
14
+ /** The attribution line above a quoted reply. */
15
+ export declare function attribution(message: Message): string;
16
+ /** A plain-text body quoted the way every mail client quotes: `> ` per line. */
17
+ export declare function quoteText(message: Message): string;
18
+ /** The block a forwarded message is introduced by. */
19
+ export declare function forwardIntroduction(message: Message): string;
20
+ export type ReplyOptions = {
21
+ /** The identity replying, so its own address is kept out of the
22
+ * recipients. */
23
+ identity: MailIdentity;
24
+ /** Reply to everyone the message went to, not only its sender. */
25
+ all?: boolean;
26
+ /** Put the original above a quoted copy. On by default. */
27
+ quote?: boolean;
28
+ };
29
+ /**
30
+ * A reply to `message`, ready to hand to `send`.
31
+ *
32
+ * `Reply-To` wins over `From` for the recipient, per RFC 5322 §3.6.2, which
33
+ * is how a mailing list or a no-reply-plus-support-address arrangement
34
+ * expects to be answered.
35
+ */
36
+ export declare function replyDraft(message: Message, options: ReplyOptions): Draft;
37
+ export type ForwardOptions = {
38
+ identity: MailIdentity;
39
+ /** Who to forward to. Left empty, the caller fills it in before sending. */
40
+ to?: readonly EmailAddress[];
41
+ /** Carry the original's attachments. On by default: a forward that drops
42
+ * them is the classic mail-client bug. */
43
+ attachments?: boolean;
44
+ };
45
+ /**
46
+ * A forward of `message`.
47
+ *
48
+ * A forward starts a new conversation, so it carries no `In-Reply-To` or
49
+ * `References`: threading it onto the original would file it under a
50
+ * conversation the new recipient cannot see.
51
+ */
52
+ export declare function forwardDraft(message: Message, options: ForwardOptions): Draft;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Building a reply or a forward from a message is pure: no client, no
3
+ * network. It is also where mail clients quietly get things wrong, so it
4
+ * lives on its own and is tested on its own.
5
+ */
6
+ /**
7
+ * Reply prefixes, so `Re: Re: Re:` does not accumulate.
8
+ *
9
+ * A localised client writes its own: Norwegian Outlook sends `SV:`, German
10
+ * `AW:`, and some clients count with `Re[2]:`. Stripping a prefix this list
11
+ * does not know is worse than leaving it, so the list stays short and
12
+ * certain.
13
+ */
14
+ const REPLY_PREFIX = /^\s*(?:(?:re|sv|aw|antw)(?:\[\d+\])?\s*:\s*)+/i;
15
+ const FORWARD_PREFIX = /^\s*(?:(?:fwd?|vs|wg)(?:\[\d+\])?\s*:\s*)+/i;
16
+ /** `Re: ` once, however many times the subject already carries it. */
17
+ export function replySubject(subject) {
18
+ return `Re: ${subject.replace(REPLY_PREFIX, '').trim()}`;
19
+ }
20
+ /** `Fwd: ` once. A forward of a reply keeps the `Re:` in the middle, which is
21
+ * what the thread was actually called. */
22
+ export function forwardSubject(subject) {
23
+ return `Fwd: ${subject.replace(FORWARD_PREFIX, '').trim()}`;
24
+ }
25
+ /** `Ada Lovelace <ada@example.com>`, or the bare address when there is no name. */
26
+ export function formatAddress(address) {
27
+ const name = address.name?.trim();
28
+ return name ? `${name} <${address.email}>` : address.email;
29
+ }
30
+ function normalise(address) {
31
+ return address.email.trim().toLowerCase();
32
+ }
33
+ /** Keep the first occurrence of each address, dropping any in `exclude`. */
34
+ function dedupe(addresses, exclude) {
35
+ const seen = new Set(exclude);
36
+ const kept = [];
37
+ for (const address of addresses) {
38
+ const key = normalise(address);
39
+ if (key === '' || seen.has(key))
40
+ continue;
41
+ seen.add(key);
42
+ kept.push(address);
43
+ }
44
+ return kept;
45
+ }
46
+ /** The attribution line above a quoted reply. */
47
+ export function attribution(message) {
48
+ // `2026-09-01T09:14:32.123Z` reads as `2026-09-01 at 09:14`: the date and
49
+ // the minute, with no timezone maths and nothing locale-dependent.
50
+ const when = (message.sentAt ?? message.receivedAt).slice(0, 16).replace('T', ' at ');
51
+ const who = message.from[0] ? formatAddress(message.from[0]) : 'someone';
52
+ return `On ${when}, ${who} wrote:`;
53
+ }
54
+ /** A plain-text body quoted the way every mail client quotes: `> ` per line. */
55
+ export function quoteText(message) {
56
+ const body = message.text ?? '';
57
+ const quoted = body
58
+ .split('\n')
59
+ // A line that is already quoted gets one more marker and no space, which
60
+ // is how a quote of a quote is meant to nest.
61
+ .map((line) => (line.startsWith('>') ? `>${line}` : `> ${line}`))
62
+ .join('\n');
63
+ return `${attribution(message)}\n${quoted}`;
64
+ }
65
+ /** The block a forwarded message is introduced by. */
66
+ export function forwardIntroduction(message) {
67
+ const lines = [
68
+ '---------- Forwarded message ----------',
69
+ `From: ${message.from.map(formatAddress).join(', ')}`,
70
+ `Date: ${message.sentAt ?? message.receivedAt}`,
71
+ `Subject: ${message.subject}`,
72
+ `To: ${message.to.map(formatAddress).join(', ')}`,
73
+ ];
74
+ if (message.cc.length > 0)
75
+ lines.push(`Cc: ${message.cc.map(formatAddress).join(', ')}`);
76
+ return `${lines.join('\n')}\n`;
77
+ }
78
+ /**
79
+ * A reply to `message`, ready to hand to `send`.
80
+ *
81
+ * `Reply-To` wins over `From` for the recipient, per RFC 5322 §3.6.2, which
82
+ * is how a mailing list or a no-reply-plus-support-address arrangement
83
+ * expects to be answered.
84
+ */
85
+ export function replyDraft(message, options) {
86
+ const { identity, all = false, quote = true } = options;
87
+ const mine = new Set([identity.email.trim().toLowerCase()]);
88
+ const answering = message.replyTo.length > 0 ? message.replyTo : message.from;
89
+ const to = dedupe(answering, mine);
90
+ // Replying to a message you sent yourself keeps its recipients, or the
91
+ // reply has nowhere to go.
92
+ const recipients = to.length > 0 ? to : dedupe(message.to, new Set());
93
+ const cc = all
94
+ ? dedupe([...message.to, ...message.cc], new Set([...mine, ...recipients.map(normalise)]))
95
+ : [];
96
+ // RFC 5322 §3.6.4: References is the parent's References plus its
97
+ // Message-ID, which is what threads the conversation for every other client.
98
+ const references = [...message.references, ...message.messageId];
99
+ return {
100
+ identityId: identity.id,
101
+ to: recipients,
102
+ ...(cc.length > 0 ? { cc } : {}),
103
+ subject: replySubject(message.subject),
104
+ ...(quote ? { text: `\n\n${quoteText(message)}` } : {}),
105
+ ...(message.messageId.length > 0 ? { inReplyTo: message.messageId } : {}),
106
+ ...(references.length > 0 ? { references } : {}),
107
+ };
108
+ }
109
+ /**
110
+ * A forward of `message`.
111
+ *
112
+ * A forward starts a new conversation, so it carries no `In-Reply-To` or
113
+ * `References`: threading it onto the original would file it under a
114
+ * conversation the new recipient cannot see.
115
+ */
116
+ export function forwardDraft(message, options) {
117
+ const { identity, to = [], attachments = true } = options;
118
+ const carried = attachments
119
+ ? message.attachments.map((attachment) => ({
120
+ blobId: attachment.blobId,
121
+ name: attachment.name,
122
+ type: attachment.type,
123
+ disposition: attachment.disposition,
124
+ ...(attachment.cid ? { cid: attachment.cid } : {}),
125
+ }))
126
+ : [];
127
+ return {
128
+ identityId: identity.id,
129
+ to,
130
+ subject: forwardSubject(message.subject),
131
+ text: `\n\n${forwardIntroduction(message)}\n${message.text ?? ''}`,
132
+ ...(carried.length > 0 ? { attachments: carried } : {}),
133
+ };
134
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * jmap-jam throws whatever the server sent: a `ProblemDetails` object for a
3
+ * request-level failure, a string for a non-JSON body, and an *array* of
4
+ * `ProblemDetails` when one call in a `requestMany` chain failed. None of
5
+ * those is an `Error`, so a caller that logs `err.message` gets `undefined`
6
+ * and a `catch` that rethrows loses the stack.
7
+ *
8
+ * Everything this package throws is a `MailError`.
9
+ */
10
+ /** The shape a JMAP server returns for a failed request or method call. */
11
+ export type ProblemDetails = {
12
+ type?: string;
13
+ status?: number;
14
+ title?: string;
15
+ detail?: string;
16
+ description?: string;
17
+ [key: string]: unknown;
18
+ };
19
+ export declare class MailError extends Error {
20
+ /** The JMAP error type, e.g. `urn:ietf:params:jmap:error:unknownCapability`
21
+ * or a method-level `invalidArguments`. `unknown` when the server sent
22
+ * something unparseable. */
23
+ readonly type: string;
24
+ /** The HTTP status, when the failure was request-level. */
25
+ readonly status: number | undefined;
26
+ /** The operation this package was performing, e.g. `threads.list`. */
27
+ readonly operation: string | undefined;
28
+ /** Every problem the server reported. A `requestMany` chain can fail in
29
+ * more than one place; `message` names the first. */
30
+ readonly problems: readonly ProblemDetails[];
31
+ constructor(message: string, options?: {
32
+ type?: string;
33
+ status?: number;
34
+ operation?: string;
35
+ problems?: readonly ProblemDetails[];
36
+ cause?: unknown;
37
+ });
38
+ }
39
+ /**
40
+ * Turn anything jmap-jam threw into a `MailError`. A `MailError` passes
41
+ * through unchanged so a rethrow up a call stack does not re-wrap.
42
+ */
43
+ export declare function toMailError(thrown: unknown, operation?: string): MailError;
44
+ /** Run `fn`, and make sure anything it throws is a `MailError` naming `operation`. */
45
+ export declare function guard<T>(operation: string, fn: () => Promise<T>): Promise<T>;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * jmap-jam throws whatever the server sent: a `ProblemDetails` object for a
3
+ * request-level failure, a string for a non-JSON body, and an *array* of
4
+ * `ProblemDetails` when one call in a `requestMany` chain failed. None of
5
+ * those is an `Error`, so a caller that logs `err.message` gets `undefined`
6
+ * and a `catch` that rethrows loses the stack.
7
+ *
8
+ * Everything this package throws is a `MailError`.
9
+ */
10
+ export class MailError extends Error {
11
+ /** The JMAP error type, e.g. `urn:ietf:params:jmap:error:unknownCapability`
12
+ * or a method-level `invalidArguments`. `unknown` when the server sent
13
+ * something unparseable. */
14
+ type;
15
+ /** The HTTP status, when the failure was request-level. */
16
+ status;
17
+ /** The operation this package was performing, e.g. `threads.list`. */
18
+ operation;
19
+ /** Every problem the server reported. A `requestMany` chain can fail in
20
+ * more than one place; `message` names the first. */
21
+ problems;
22
+ constructor(message, options = {}) {
23
+ super(message, { cause: options.cause });
24
+ this.name = 'MailError';
25
+ this.type = options.type ?? 'unknown';
26
+ this.status = options.status;
27
+ this.operation = options.operation;
28
+ this.problems = options.problems ?? [];
29
+ }
30
+ }
31
+ function isProblem(value) {
32
+ // An `Error` is an object too, and treating one as a problem details would
33
+ // throw away its message and its stack.
34
+ return (typeof value === 'object' &&
35
+ value !== null &&
36
+ !Array.isArray(value) &&
37
+ !(value instanceof Error));
38
+ }
39
+ /** The most useful sentence a problem carries, in the order JMAP servers
40
+ * actually populate the fields. */
41
+ function describe(problem) {
42
+ const detail = problem.detail ?? problem.description ?? problem.title;
43
+ const type = typeof problem.type === 'string' ? problem.type : undefined;
44
+ if (typeof detail === 'string' && detail.length > 0) {
45
+ return type ? `${type}: ${detail}` : detail;
46
+ }
47
+ return type ?? 'the server reported an error with no description';
48
+ }
49
+ /**
50
+ * Turn anything jmap-jam threw into a `MailError`. A `MailError` passes
51
+ * through unchanged so a rethrow up a call stack does not re-wrap.
52
+ */
53
+ export function toMailError(thrown, operation) {
54
+ if (thrown instanceof MailError)
55
+ return thrown;
56
+ const problems = Array.isArray(thrown)
57
+ ? thrown.filter(isProblem)
58
+ : isProblem(thrown)
59
+ ? [thrown]
60
+ : [];
61
+ const first = problems[0];
62
+ if (first) {
63
+ const rest = problems.length > 1 ? ` (and ${problems.length - 1} more)` : '';
64
+ return new MailError(`${describe(first)}${rest}`, {
65
+ type: typeof first.type === 'string' ? first.type : undefined,
66
+ status: typeof first.status === 'number' ? first.status : undefined,
67
+ operation,
68
+ problems,
69
+ cause: thrown,
70
+ });
71
+ }
72
+ if (thrown instanceof Error) {
73
+ return new MailError(thrown.message, { operation, cause: thrown });
74
+ }
75
+ return new MailError(typeof thrown === 'string' ? thrown : 'the request failed', {
76
+ operation,
77
+ cause: thrown,
78
+ });
79
+ }
80
+ /** Run `fn`, and make sure anything it throws is a `MailError` naming `operation`. */
81
+ export async function guard(operation, fn) {
82
+ try {
83
+ return await fn();
84
+ }
85
+ catch (thrown) {
86
+ throw toMailError(thrown, operation);
87
+ }
88
+ }
@@ -0,0 +1,34 @@
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.ts';
13
+ import { FakeMailbox, type FakeOptions, type SeedMessage, type SeedThread } from './mailbox.ts';
14
+ import { SAMPLE } from './sample.ts';
15
+ export { FakeMailbox };
16
+ export type { FakeOptions, SeedMessage, SeedThread };
17
+ export { SAMPLE };
18
+ export type FakeMail = {
19
+ /** A real `MailClient`. Everything this package exports works against it
20
+ * unchanged, through the real wire code. */
21
+ client: MailClient;
22
+ /** The mailbox behind it, to seed more mail or to assert on what was sent. */
23
+ mailbox: FakeMailbox;
24
+ /** Stop intercepting. Call it when a story or a test is done; leaving it
25
+ * installed leaves the fake answering for the rest of the process. */
26
+ dispose: () => void;
27
+ };
28
+ /**
29
+ * A client and the mailbox it talks to.
30
+ *
31
+ * Seeded with `SAMPLE` unless told otherwise, because a fake with an empty
32
+ * inbox demonstrates the empty state and nothing else.
33
+ */
34
+ export declare function fakeMail(options?: FakeOptions): FakeMail;