@vllnt/convex-email 0.1.0-canary.63aca6b
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.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/client/index.d.ts +174 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +151 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +84 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +3 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +40 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +110 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/crons.d.ts +16 -0
- package/dist/component/crons.d.ts.map +1 -0
- package/dist/component/crons.js +19 -0
- package/dist/component/crons.js.map +1 -0
- package/dist/component/mutations.d.ts +95 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +243 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +59 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +61 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +54 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +40 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/validators.d.ts +54 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +40 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/jmap/index.d.ts +22 -0
- package/dist/jmap/index.d.ts.map +1 -0
- package/dist/jmap/index.js +21 -0
- package/dist/jmap/index.js.map +1 -0
- package/dist/jmap/send.d.ts +125 -0
- package/dist/jmap/send.d.ts.map +1 -0
- package/dist/jmap/send.js +418 -0
- package/dist/jmap/send.js.map +1 -0
- package/dist/jmap/types.d.ts +107 -0
- package/dist/jmap/types.d.ts.map +1 -0
- package/dist/jmap/types.js +16 -0
- package/dist/jmap/types.js.map +1 -0
- package/dist/shared.d.ts +32 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +33 -0
- package/dist/shared.js.map +1 -0
- package/dist/smtp/index.d.ts +22 -0
- package/dist/smtp/index.d.ts.map +1 -0
- package/dist/smtp/index.js +21 -0
- package/dist/smtp/index.js.map +1 -0
- package/dist/smtp/send.d.ts +51 -0
- package/dist/smtp/send.d.ts.map +1 -0
- package/dist/smtp/send.js +124 -0
- package/dist/smtp/send.js.map +1 -0
- package/dist/smtp/transport.d.ts +43 -0
- package/dist/smtp/transport.d.ts.map +1 -0
- package/dist/smtp/transport.js +55 -0
- package/dist/smtp/transport.js.map +1 -0
- package/dist/smtp/types.d.ts +122 -0
- package/dist/smtp/types.d.ts.map +1 -0
- package/dist/smtp/types.js +9 -0
- package/dist/smtp/types.js.map +1 -0
- package/package.json +118 -0
- package/src/client/index.ts +312 -0
- package/src/client/types.ts +90 -0
- package/src/component/_generated/api.ts +56 -0
- package/src/component/_generated/component.ts +134 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/crons.ts +23 -0
- package/src/component/mutations.ts +262 -0
- package/src/component/queries.ts +70 -0
- package/src/component/schema.ts +40 -0
- package/src/component/validators.ts +47 -0
- package/src/jmap/index.ts +39 -0
- package/src/jmap/send.test.ts +565 -0
- package/src/jmap/send.ts +502 -0
- package/src/jmap/types.ts +117 -0
- package/src/shared.ts +41 -0
- package/src/smtp/index.ts +30 -0
- package/src/smtp/send.test.ts +240 -0
- package/src/smtp/send.ts +154 -0
- package/src/smtp/transport.ts +58 -0
- package/src/smtp/types.ts +124 -0
- package/src/test.ts +12 -0
package/src/jmap/send.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, injectable generic-JMAP send logic. Everything here is runtime-neutral
|
|
3
|
+
* and network-free: it drives an injected {@link JmapFetch} (the host's runtime
|
|
4
|
+
* `fetch`), so a fake gives full coverage with no socket. There is no Node-only
|
|
5
|
+
* piece — JMAP is plain HTTP — so the whole adapter is covered at 100%.
|
|
6
|
+
*
|
|
7
|
+
* Sending is the JMAP two-call batch (RFC 8621): `Email/set` creates the message
|
|
8
|
+
* in a mailbox, then `EmailSubmission/set` submits it. {@link discoverJmapSession}
|
|
9
|
+
* resolves the account / identity / mailbox ids from a JMAP session so a host need
|
|
10
|
+
* not hand-wire them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
JmapConfig,
|
|
15
|
+
JmapDiscoverOptions,
|
|
16
|
+
JmapFetch,
|
|
17
|
+
JmapMessage,
|
|
18
|
+
JmapSendResult,
|
|
19
|
+
JmapSender,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** A control character (CR/LF) that must never reach a header value. */
|
|
23
|
+
const CRLF = /[\r\n]/;
|
|
24
|
+
|
|
25
|
+
/** The JMAP capability URNs the adapter uses. */
|
|
26
|
+
const CAP_CORE = "urn:ietf:params:jmap:core";
|
|
27
|
+
const CAP_MAIL = "urn:ietf:params:jmap:mail";
|
|
28
|
+
const CAP_SUBMISSION = "urn:ietf:params:jmap:submission";
|
|
29
|
+
|
|
30
|
+
/** Narrow an unknown value to a plain object without a type assertion. */
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === "object" && value !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Guard a single header-bound value against CR/LF injection. */
|
|
36
|
+
function assertNoCrlf(label: string, value: string): void {
|
|
37
|
+
if (CRLF.test(value)) {
|
|
38
|
+
throw new Error(`jmap message: \`${label}\` must not contain CR or LF`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Split a comma-separated address list into trimmed, non-empty addresses. */
|
|
43
|
+
function splitAddresses(raw: string): string[] {
|
|
44
|
+
return raw
|
|
45
|
+
.split(",")
|
|
46
|
+
.map((a) => a.trim())
|
|
47
|
+
.filter((a) => a.length > 0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate a host-supplied {@link JmapConfig}: every id must be a non-empty
|
|
52
|
+
* string. Throws a plain `Error` on an invalid config — the host surfaces it.
|
|
53
|
+
* Pure: no I/O.
|
|
54
|
+
*
|
|
55
|
+
* @param config - The resolved JMAP connection config.
|
|
56
|
+
* @returns The same config, unchanged.
|
|
57
|
+
*/
|
|
58
|
+
export function validateJmapConfig(config: JmapConfig): JmapConfig {
|
|
59
|
+
const keys = [
|
|
60
|
+
"endpoint",
|
|
61
|
+
"token",
|
|
62
|
+
"accountId",
|
|
63
|
+
"identityId",
|
|
64
|
+
"mailboxId",
|
|
65
|
+
] as const;
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
const value = config[key];
|
|
68
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
69
|
+
throw new Error(`jmap config: \`${key}\` must be a non-empty string`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return config;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The JMAP `Email` create object plus the submission envelope for one message. */
|
|
76
|
+
interface EmailCreate {
|
|
77
|
+
/** The JMAP `Email/set` create object. */
|
|
78
|
+
email: Record<string, unknown>;
|
|
79
|
+
/** The `EmailSubmission` envelope (`mailFrom` + `rcptTo`). */
|
|
80
|
+
envelope: {
|
|
81
|
+
mailFrom: { email: string };
|
|
82
|
+
rcptTo: Array<{ email: string }>;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build the JMAP `Email` create object and the submission envelope from a
|
|
88
|
+
* {@link JmapMessage}: resolve `from` (message → `config.from`), require one of
|
|
89
|
+
* `text`/`html`, and reject CR/LF in `to`/`from`/`replyTo`/`subject`/`headers`.
|
|
90
|
+
* Pure.
|
|
91
|
+
*
|
|
92
|
+
* @param message - The outbound message.
|
|
93
|
+
* @param config - The resolved config (for the `from` default + the mailbox).
|
|
94
|
+
* @returns The `Email` create object and the submission envelope.
|
|
95
|
+
*/
|
|
96
|
+
export function buildEmailCreate(
|
|
97
|
+
message: JmapMessage,
|
|
98
|
+
config: Pick<JmapConfig, "from" | "mailboxId">,
|
|
99
|
+
): EmailCreate {
|
|
100
|
+
if (typeof message.to !== "string" || message.to.trim() === "") {
|
|
101
|
+
throw new Error("jmap message: `to` must be a non-empty string");
|
|
102
|
+
}
|
|
103
|
+
const from = message.from ?? config.from;
|
|
104
|
+
if (from === undefined || from.trim() === "") {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"jmap message: `from` is required (pass `message.from` or `config.from`)",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (message.text === undefined && message.html === undefined) {
|
|
110
|
+
throw new Error("jmap message: one of `text` or `html` is required");
|
|
111
|
+
}
|
|
112
|
+
const recipients = splitAddresses(message.to);
|
|
113
|
+
if (recipients.length === 0) {
|
|
114
|
+
throw new Error("jmap message: `to` must contain at least one address");
|
|
115
|
+
}
|
|
116
|
+
assertNoCrlf("from", from);
|
|
117
|
+
for (const rcpt of recipients) {
|
|
118
|
+
assertNoCrlf("to", rcpt);
|
|
119
|
+
}
|
|
120
|
+
if (message.replyTo !== undefined) {
|
|
121
|
+
assertNoCrlf("replyTo", message.replyTo);
|
|
122
|
+
}
|
|
123
|
+
if (message.subject !== undefined) {
|
|
124
|
+
assertNoCrlf("subject", message.subject);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let body: Record<string, unknown>;
|
|
128
|
+
if (message.text !== undefined && message.html !== undefined) {
|
|
129
|
+
body = {
|
|
130
|
+
bodyStructure: {
|
|
131
|
+
type: "multipart/alternative",
|
|
132
|
+
subParts: [
|
|
133
|
+
{ type: "text/plain", partId: "text" },
|
|
134
|
+
{ type: "text/html", partId: "html" },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
bodyValues: {
|
|
138
|
+
text: { value: message.text },
|
|
139
|
+
html: { value: message.html },
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
} else if (message.html !== undefined) {
|
|
143
|
+
body = {
|
|
144
|
+
bodyStructure: { type: "text/html", partId: "html" },
|
|
145
|
+
bodyValues: { html: { value: message.html } },
|
|
146
|
+
};
|
|
147
|
+
} else {
|
|
148
|
+
body = {
|
|
149
|
+
bodyStructure: { type: "text/plain", partId: "text" },
|
|
150
|
+
bodyValues: { text: { value: message.text } },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const email: Record<string, unknown> = {
|
|
155
|
+
mailboxIds: { [config.mailboxId]: true },
|
|
156
|
+
keywords: { $seen: true },
|
|
157
|
+
from: [{ email: from }],
|
|
158
|
+
to: recipients.map((email) => ({ email })),
|
|
159
|
+
...(message.subject !== undefined ? { subject: message.subject } : {}),
|
|
160
|
+
...(message.replyTo !== undefined
|
|
161
|
+
? { replyTo: [{ email: message.replyTo }] }
|
|
162
|
+
: {}),
|
|
163
|
+
...body,
|
|
164
|
+
};
|
|
165
|
+
if (message.headers !== undefined) {
|
|
166
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
167
|
+
assertNoCrlf(`headers.${key}`, key);
|
|
168
|
+
assertNoCrlf(`headers.${key}`, value);
|
|
169
|
+
email[`header:${key}:asText`] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
email,
|
|
175
|
+
envelope: {
|
|
176
|
+
mailFrom: { email: from },
|
|
177
|
+
rcptTo: recipients.map((email) => ({ email })),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build the full JMAP request body for sending one {@link JmapMessage}: an
|
|
184
|
+
* `Email/set` create (creation id `draft`) followed by an `EmailSubmission/set`
|
|
185
|
+
* that back-references it (`#draft`). Pure.
|
|
186
|
+
*
|
|
187
|
+
* @param message - The message to send.
|
|
188
|
+
* @param config - The resolved JMAP config.
|
|
189
|
+
* @returns The JMAP request body to POST to the endpoint.
|
|
190
|
+
*/
|
|
191
|
+
export function buildSubmitRequest(
|
|
192
|
+
message: JmapMessage,
|
|
193
|
+
config: JmapConfig,
|
|
194
|
+
): { using: string[]; methodCalls: unknown[] } {
|
|
195
|
+
const { email, envelope } = buildEmailCreate(message, config);
|
|
196
|
+
return {
|
|
197
|
+
using: [CAP_CORE, CAP_MAIL, CAP_SUBMISSION],
|
|
198
|
+
methodCalls: [
|
|
199
|
+
["Email/set", { accountId: config.accountId, create: { draft: email } }, "0"],
|
|
200
|
+
[
|
|
201
|
+
"EmailSubmission/set",
|
|
202
|
+
{
|
|
203
|
+
accountId: config.accountId,
|
|
204
|
+
create: {
|
|
205
|
+
sub: {
|
|
206
|
+
emailId: "#draft",
|
|
207
|
+
identityId: config.identityId,
|
|
208
|
+
envelope,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
"1",
|
|
213
|
+
],
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Find the arguments of the first method response with the given name. */
|
|
219
|
+
function findInvocation(
|
|
220
|
+
responses: unknown[],
|
|
221
|
+
name: string,
|
|
222
|
+
): Record<string, unknown> | null {
|
|
223
|
+
for (const inv of responses) {
|
|
224
|
+
if (Array.isArray(inv) && inv[0] === name && isRecord(inv[1])) {
|
|
225
|
+
return inv[1];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Describe a JMAP `SetError` (its `type`, or `"unknown"`). */
|
|
232
|
+
function describeSetError(value: unknown): string {
|
|
233
|
+
return isRecord(value) && typeof value.type === "string"
|
|
234
|
+
? value.type
|
|
235
|
+
: "unknown";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Read a created object's id from a `Foo/set` response, throwing on `notCreated`. */
|
|
239
|
+
function readCreatedId(
|
|
240
|
+
args: Record<string, unknown>,
|
|
241
|
+
createId: string,
|
|
242
|
+
label: string,
|
|
243
|
+
): string {
|
|
244
|
+
const notCreated = args.notCreated;
|
|
245
|
+
if (isRecord(notCreated) && createId in notCreated) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`jmap: ${label} not created (${describeSetError(notCreated[createId])})`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const created = args.created;
|
|
251
|
+
if (isRecord(created)) {
|
|
252
|
+
const entry = created[createId];
|
|
253
|
+
if (isRecord(entry) && typeof entry.id === "string") {
|
|
254
|
+
return entry.id;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`jmap: ${label} not created (no id in response)`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Parse a JMAP send response into a {@link JmapSendResult}: reject method-level
|
|
262
|
+
* errors and `notCreated` entries, then read the created `Email` id and the
|
|
263
|
+
* `EmailSubmission` id. Throws on any failure (the host catches it and calls
|
|
264
|
+
* `markFailed`). Pure.
|
|
265
|
+
*
|
|
266
|
+
* @param json - The parsed JMAP response body.
|
|
267
|
+
* @returns The created email id and submission id (the latter as `messageId`).
|
|
268
|
+
*/
|
|
269
|
+
export function parseSubmitResponse(json: unknown): JmapSendResult {
|
|
270
|
+
if (!isRecord(json) || !Array.isArray(json.methodResponses)) {
|
|
271
|
+
throw new Error("jmap: malformed response (no methodResponses)");
|
|
272
|
+
}
|
|
273
|
+
const responses = json.methodResponses;
|
|
274
|
+
for (const inv of responses) {
|
|
275
|
+
if (Array.isArray(inv) && inv[0] === "error") {
|
|
276
|
+
throw new Error(`jmap: method error (${describeSetError(inv[1])})`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const emailArgs = findInvocation(responses, "Email/set");
|
|
280
|
+
const subArgs = findInvocation(responses, "EmailSubmission/set");
|
|
281
|
+
if (emailArgs === null || subArgs === null) {
|
|
282
|
+
throw new Error("jmap: response missing Email/set or EmailSubmission/set");
|
|
283
|
+
}
|
|
284
|
+
const emailId = readCreatedId(emailArgs, "draft", "Email");
|
|
285
|
+
const messageId = readCreatedId(subArgs, "sub", "EmailSubmission");
|
|
286
|
+
return { messageId, emailId };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Send one {@link JmapMessage} through an injected {@link JmapFetch} and return a
|
|
291
|
+
* normalized {@link JmapSendResult}. Validates the config, builds the two-call
|
|
292
|
+
* JMAP batch, POSTs it, and parses the result. Throws on an HTTP or JMAP error
|
|
293
|
+
* (the host catches it and calls `markFailed`). Pure: pass the host's runtime
|
|
294
|
+
* `fetch` in production, or a fake in a unit test.
|
|
295
|
+
*
|
|
296
|
+
* @param fetchFn - The injected `fetch` (the host's runtime `fetch`, or a fake).
|
|
297
|
+
* @param message - The message to send.
|
|
298
|
+
* @param config - The resolved JMAP config.
|
|
299
|
+
* @returns The normalized send result — store `messageId` as the queue `providerId`.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```ts
|
|
303
|
+
* // host Convex action (no "use node" — fetch runs in V8):
|
|
304
|
+
* const { messageId } = await sendViaJmap((u, i) => fetch(u, i), { to, html }, config);
|
|
305
|
+
* await email.markSent(ctx, id, { providerId: messageId });
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
export async function sendViaJmap(
|
|
309
|
+
fetchFn: JmapFetch,
|
|
310
|
+
message: JmapMessage,
|
|
311
|
+
config: JmapConfig,
|
|
312
|
+
): Promise<JmapSendResult> {
|
|
313
|
+
validateJmapConfig(config);
|
|
314
|
+
const body = buildSubmitRequest(message, config);
|
|
315
|
+
const res = await fetchFn(config.endpoint, {
|
|
316
|
+
method: "POST",
|
|
317
|
+
headers: {
|
|
318
|
+
Authorization: `Bearer ${config.token}`,
|
|
319
|
+
"Content-Type": "application/json",
|
|
320
|
+
Accept: "application/json",
|
|
321
|
+
},
|
|
322
|
+
body: JSON.stringify(body),
|
|
323
|
+
});
|
|
324
|
+
if (!res.ok) {
|
|
325
|
+
throw new Error(`jmap: request failed (HTTP ${res.status})`);
|
|
326
|
+
}
|
|
327
|
+
return parseSubmitResponse(await res.json());
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** POST a JMAP request and return its `methodResponses`, throwing on HTTP/shape errors. */
|
|
331
|
+
async function jmapCall(
|
|
332
|
+
fetchFn: JmapFetch,
|
|
333
|
+
endpoint: string,
|
|
334
|
+
token: string,
|
|
335
|
+
request: { using: string[]; methodCalls: unknown[] },
|
|
336
|
+
): Promise<unknown[]> {
|
|
337
|
+
const res = await fetchFn(endpoint, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: {
|
|
340
|
+
Authorization: `Bearer ${token}`,
|
|
341
|
+
"Content-Type": "application/json",
|
|
342
|
+
Accept: "application/json",
|
|
343
|
+
},
|
|
344
|
+
body: JSON.stringify(request),
|
|
345
|
+
});
|
|
346
|
+
if (!res.ok) {
|
|
347
|
+
throw new Error(`jmap: request failed (HTTP ${res.status})`);
|
|
348
|
+
}
|
|
349
|
+
const json = await res.json();
|
|
350
|
+
if (!isRecord(json) || !Array.isArray(json.methodResponses)) {
|
|
351
|
+
throw new Error("jmap: malformed response (no methodResponses)");
|
|
352
|
+
}
|
|
353
|
+
return json.methodResponses;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Pick the sending identity (by `from`, else the first) from an `Identity/get` list. */
|
|
357
|
+
function pickIdentity(
|
|
358
|
+
list: unknown[],
|
|
359
|
+
from?: string,
|
|
360
|
+
): { id: string; email: string } | null {
|
|
361
|
+
const valid: Array<{ id: string; email: string }> = [];
|
|
362
|
+
for (const item of list) {
|
|
363
|
+
if (
|
|
364
|
+
isRecord(item) &&
|
|
365
|
+
typeof item.id === "string" &&
|
|
366
|
+
typeof item.email === "string"
|
|
367
|
+
) {
|
|
368
|
+
valid.push({ id: item.id, email: item.email });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const lower = from?.toLowerCase();
|
|
372
|
+
const matched =
|
|
373
|
+
lower !== undefined
|
|
374
|
+
? valid.find((i) => i.email.toLowerCase() === lower)
|
|
375
|
+
: undefined;
|
|
376
|
+
return matched ?? valid[0] ?? null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Pick the mailbox to file the sent copy in: role `sent`, else `drafts`, else first. */
|
|
380
|
+
function pickMailboxId(list: unknown[]): string | null {
|
|
381
|
+
const valid: Array<{ id: string; role: string | null }> = [];
|
|
382
|
+
for (const item of list) {
|
|
383
|
+
if (isRecord(item) && typeof item.id === "string") {
|
|
384
|
+
valid.push({
|
|
385
|
+
id: item.id,
|
|
386
|
+
role: typeof item.role === "string" ? item.role : null,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const sent = valid.find((m) => m.role === "sent");
|
|
391
|
+
if (sent !== undefined) {
|
|
392
|
+
return sent.id;
|
|
393
|
+
}
|
|
394
|
+
const drafts = valid.find((m) => m.role === "drafts");
|
|
395
|
+
if (drafts !== undefined) {
|
|
396
|
+
return drafts.id;
|
|
397
|
+
}
|
|
398
|
+
return valid[0]?.id ?? null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resolve a {@link JmapConfig} from a JMAP session: fetch the session resource
|
|
403
|
+
* for the `apiUrl` + primary mail account, then `Identity/get` for the sending
|
|
404
|
+
* identity and `Mailbox/get` for the Sent (or Drafts) mailbox. Pure: drives the
|
|
405
|
+
* injected {@link JmapFetch}. The host runs this once and passes the result to
|
|
406
|
+
* {@link createJmapSender} / {@link sendViaJmap}.
|
|
407
|
+
*
|
|
408
|
+
* @param fetchFn - The injected `fetch`.
|
|
409
|
+
* @param opts - The session URL, bearer token, and optional preferred `from`.
|
|
410
|
+
* @returns A resolved {@link JmapConfig} ready to send with.
|
|
411
|
+
*/
|
|
412
|
+
export async function discoverJmapSession(
|
|
413
|
+
fetchFn: JmapFetch,
|
|
414
|
+
opts: JmapDiscoverOptions,
|
|
415
|
+
): Promise<JmapConfig> {
|
|
416
|
+
if (typeof opts.sessionUrl !== "string" || opts.sessionUrl.trim() === "") {
|
|
417
|
+
throw new Error("jmap discover: `sessionUrl` must be a non-empty string");
|
|
418
|
+
}
|
|
419
|
+
if (typeof opts.token !== "string" || opts.token.trim() === "") {
|
|
420
|
+
throw new Error("jmap discover: `token` must be a non-empty string");
|
|
421
|
+
}
|
|
422
|
+
const sres = await fetchFn(opts.sessionUrl, {
|
|
423
|
+
method: "GET",
|
|
424
|
+
headers: { Authorization: `Bearer ${opts.token}`, Accept: "application/json" },
|
|
425
|
+
});
|
|
426
|
+
if (!sres.ok) {
|
|
427
|
+
throw new Error(`jmap discover: session request failed (HTTP ${sres.status})`);
|
|
428
|
+
}
|
|
429
|
+
const session = await sres.json();
|
|
430
|
+
if (!isRecord(session) || typeof session.apiUrl !== "string") {
|
|
431
|
+
throw new Error("jmap discover: session has no apiUrl");
|
|
432
|
+
}
|
|
433
|
+
const accounts = session.primaryAccounts;
|
|
434
|
+
const accountId = isRecord(accounts) ? accounts[CAP_MAIL] : undefined;
|
|
435
|
+
if (typeof accountId !== "string") {
|
|
436
|
+
throw new Error("jmap discover: no primary mail account");
|
|
437
|
+
}
|
|
438
|
+
const endpoint = session.apiUrl;
|
|
439
|
+
|
|
440
|
+
const idResponses = await jmapCall(fetchFn, endpoint, opts.token, {
|
|
441
|
+
using: [CAP_CORE, CAP_SUBMISSION],
|
|
442
|
+
methodCalls: [["Identity/get", { accountId, ids: null }, "0"]],
|
|
443
|
+
});
|
|
444
|
+
const idArgs = findInvocation(idResponses, "Identity/get");
|
|
445
|
+
const identity = pickIdentity(
|
|
446
|
+
idArgs !== null && Array.isArray(idArgs.list) ? idArgs.list : [],
|
|
447
|
+
opts.from,
|
|
448
|
+
);
|
|
449
|
+
if (identity === null) {
|
|
450
|
+
throw new Error("jmap discover: no sending identity found");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const mbResponses = await jmapCall(fetchFn, endpoint, opts.token, {
|
|
454
|
+
using: [CAP_CORE, CAP_MAIL],
|
|
455
|
+
methodCalls: [["Mailbox/get", { accountId, ids: null }, "0"]],
|
|
456
|
+
});
|
|
457
|
+
const mbArgs = findInvocation(mbResponses, "Mailbox/get");
|
|
458
|
+
const mailboxId = pickMailboxId(
|
|
459
|
+
mbArgs !== null && Array.isArray(mbArgs.list) ? mbArgs.list : [],
|
|
460
|
+
);
|
|
461
|
+
if (mailboxId === null) {
|
|
462
|
+
throw new Error("jmap discover: no sent or drafts mailbox found");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
endpoint,
|
|
467
|
+
token: opts.token,
|
|
468
|
+
accountId,
|
|
469
|
+
identityId: identity.id,
|
|
470
|
+
mailboxId,
|
|
471
|
+
from: opts.from ?? identity.email,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Build a bound {@link JmapSender} over a resolved config and an injected
|
|
477
|
+
* `fetch`: a function that sends one message and returns the normalized result.
|
|
478
|
+
* Validates the config eagerly. This is the one call a host wires into its flush
|
|
479
|
+
* action (a plain Convex action — no `"use node"`).
|
|
480
|
+
*
|
|
481
|
+
* @param config - The resolved JMAP config (e.g. from {@link discoverJmapSession}).
|
|
482
|
+
* @param fetchFn - The host's runtime `fetch`.
|
|
483
|
+
* @returns A sender that dispatches one {@link JmapMessage} via the configured server.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* ```ts
|
|
487
|
+
* const config = await discoverJmapSession((u, i) => fetch(u, i), {
|
|
488
|
+
* sessionUrl: "https://mail.example.com/.well-known/jmap",
|
|
489
|
+
* token: process.env.JMAP_TOKEN!,
|
|
490
|
+
* from: "no-reply@app.com",
|
|
491
|
+
* });
|
|
492
|
+
* const send = createJmapSender(config, (u, i) => fetch(u, i));
|
|
493
|
+
* const { messageId } = await send({ to, subject, html });
|
|
494
|
+
* ```
|
|
495
|
+
*/
|
|
496
|
+
export function createJmapSender(
|
|
497
|
+
config: JmapConfig,
|
|
498
|
+
fetchFn: JmapFetch,
|
|
499
|
+
): JmapSender {
|
|
500
|
+
validateJmapConfig(config);
|
|
501
|
+
return (message) => sendViaJmap(fetchFn, message, config);
|
|
502
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript surface for the optional generic-JMAP transport adapter.
|
|
3
|
+
*
|
|
4
|
+
* Import this from your own Convex action to send queued messages over any JMAP
|
|
5
|
+
* server's HTTP API — Stalwart, Fastmail, Cyrus, Apache James. JMAP is an open
|
|
6
|
+
* protocol (RFC 8620 core + 8621 mail/submission), so this adapter is generic
|
|
7
|
+
* over any JMAP server: the server is host config, never baked in. The component
|
|
8
|
+
* itself never sends — it records the message and its status.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the SMTP adapter, this layer is **pure**: it drives an injected
|
|
11
|
+
* {@link JmapFetch} (the host's runtime `fetch`), so it needs no Node runtime,
|
|
12
|
+
* no `"use node"` action, and no third-party dependency, and is 100%-coverable
|
|
13
|
+
* with a fake `fetch`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The minimal `Response` subset {@link JmapFetch} resolves with. */
|
|
17
|
+
export interface JmapResponse {
|
|
18
|
+
/** Whether the HTTP status is in the 2xx range. */
|
|
19
|
+
ok: boolean;
|
|
20
|
+
/** The HTTP status code. */
|
|
21
|
+
status: number;
|
|
22
|
+
/** Parse the response body as JSON. */
|
|
23
|
+
json(): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The minimal `RequestInit` subset the adapter passes to {@link JmapFetch}. */
|
|
27
|
+
export interface JmapRequestInit {
|
|
28
|
+
/** HTTP method (`"GET"` for session discovery, `"POST"` for the JMAP API). */
|
|
29
|
+
method: string;
|
|
30
|
+
/** Request headers — always carries `Authorization` and (for POST) `Content-Type`. */
|
|
31
|
+
headers: Record<string, string>;
|
|
32
|
+
/** The JSON request body (POST only). */
|
|
33
|
+
body?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The injected `fetch` seam — the minimal surface the adapter drives. The host
|
|
38
|
+
* passes its runtime `fetch` (`(url, init) => fetch(url, init)` in a Convex
|
|
39
|
+
* action); a fake one satisfies the same shape in tests, so the whole adapter is
|
|
40
|
+
* 100%-coverable with no network.
|
|
41
|
+
*/
|
|
42
|
+
export type JmapFetch = (
|
|
43
|
+
url: string,
|
|
44
|
+
init: JmapRequestInit,
|
|
45
|
+
) => Promise<JmapResponse>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolved JMAP connection config — everything {@link sendViaJmap} needs to send.
|
|
49
|
+
* Host-supplied, or produced by {@link discoverJmapSession}. Generic over any
|
|
50
|
+
* JMAP server: Stalwart is one configured endpoint, never baked in.
|
|
51
|
+
*/
|
|
52
|
+
export interface JmapConfig {
|
|
53
|
+
/** The JMAP API endpoint the method calls POST to (the session's `apiUrl`, e.g. `https://mail.example.com/jmap`). */
|
|
54
|
+
endpoint: string;
|
|
55
|
+
/** The bearer access token (a secret — keep it server-side; never ships to a client). */
|
|
56
|
+
token: string;
|
|
57
|
+
/** The JMAP account id that owns the mailbox/identity (the `urn:ietf:params:jmap:mail` primary account). */
|
|
58
|
+
accountId: string;
|
|
59
|
+
/** The sending identity id (an `Identity` whose address matches `from`). */
|
|
60
|
+
identityId: string;
|
|
61
|
+
/** The mailbox the sent copy is filed in (typically the Sent mailbox; a JMAP `Email` must belong to a mailbox). */
|
|
62
|
+
mailboxId: string;
|
|
63
|
+
/** Default `From` address used when a {@link JmapMessage} omits `from`. */
|
|
64
|
+
from?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A single outbound message handed to the JMAP transport. Mirrors the queue's
|
|
69
|
+
* stored fields (`to`/`from` plus the rendered body) without coupling to the
|
|
70
|
+
* component's storage shape — the host maps a stored payload onto it. Identical
|
|
71
|
+
* shape to the SMTP adapter's message so a host can target either transport.
|
|
72
|
+
*/
|
|
73
|
+
export interface JmapMessage {
|
|
74
|
+
/** The recipient address (one address or a comma-separated list). */
|
|
75
|
+
to: string;
|
|
76
|
+
/** The sender address; falls back to {@link JmapConfig.from} when omitted. */
|
|
77
|
+
from?: string;
|
|
78
|
+
/** The message subject line. */
|
|
79
|
+
subject?: string;
|
|
80
|
+
/** The plain-text body. At least one of `text`/`html` should be set. */
|
|
81
|
+
text?: string;
|
|
82
|
+
/** The HTML body. At least one of `text`/`html` should be set. */
|
|
83
|
+
html?: string;
|
|
84
|
+
/** Optional `Reply-To` address. */
|
|
85
|
+
replyTo?: string;
|
|
86
|
+
/** Optional extra headers (host-supplied, set as JMAP `header:Name:asText` properties). */
|
|
87
|
+
headers?: Record<string, string>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The normalized result of {@link sendViaJmap}. `messageId` is the JMAP
|
|
92
|
+
* `EmailSubmission` id (store it as the queue's `providerId` on `markSent`);
|
|
93
|
+
* `emailId` is the created `Email` object id.
|
|
94
|
+
*/
|
|
95
|
+
export interface JmapSendResult {
|
|
96
|
+
/** The `EmailSubmission` id (or the `Email` id if the server returned no submission id) — store as the queue `providerId`. */
|
|
97
|
+
messageId: string;
|
|
98
|
+
/** The created `Email` object id. */
|
|
99
|
+
emailId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A bound sender: a function that sends one {@link JmapMessage} through a
|
|
104
|
+
* preconfigured endpoint + `fetch`. {@link createJmapSender} returns one; the
|
|
105
|
+
* host calls it inside its own Convex action.
|
|
106
|
+
*/
|
|
107
|
+
export type JmapSender = (message: JmapMessage) => Promise<JmapSendResult>;
|
|
108
|
+
|
|
109
|
+
/** Options for {@link discoverJmapSession} — resolve a {@link JmapConfig} from a JMAP session. */
|
|
110
|
+
export interface JmapDiscoverOptions {
|
|
111
|
+
/** The JMAP session resource URL (e.g. `https://mail.example.com/.well-known/jmap`). */
|
|
112
|
+
sessionUrl: string;
|
|
113
|
+
/** The bearer access token. */
|
|
114
|
+
token: string;
|
|
115
|
+
/** Pick the sending identity whose address matches this `from`; otherwise the first identity is used (and becomes the config default `from`). */
|
|
116
|
+
from?: string;
|
|
117
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Shared constants used by both `client/` and `component/`. */
|
|
2
|
+
|
|
3
|
+
export const COMPONENT_NAME = "email";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard message lifecycle states. `queued` is the freshly-enqueued state;
|
|
7
|
+
* the host's transport sender claims it (`sending`), then records a terminal
|
|
8
|
+
* `sent` or `failed`. A `sending` message can return to `queued` for a retry
|
|
9
|
+
* while attempts remain. Terminal states are final — once `sent`, or `failed`
|
|
10
|
+
* with attempts exhausted, the message never transitions again.
|
|
11
|
+
*/
|
|
12
|
+
export const MESSAGE_STATUSES = ["queued", "sending", "sent", "failed"] as const;
|
|
13
|
+
|
|
14
|
+
/** A single message lifecycle status. */
|
|
15
|
+
export type MessageStatus = (typeof MESSAGE_STATUSES)[number];
|
|
16
|
+
|
|
17
|
+
/** The two terminal states — once reached, a message never transitions again. */
|
|
18
|
+
export const TERMINAL_STATUSES: ReadonlySet<MessageStatus> = new Set([
|
|
19
|
+
"sent",
|
|
20
|
+
"failed",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default maximum send attempts before a `markFailed` is permanent (the message
|
|
25
|
+
* lands in terminal `failed` instead of returning to `queued` for another
|
|
26
|
+
* attempt). The host owns the actual transport send and reports each outcome; the
|
|
27
|
+
* component only counts attempts and decides retry-vs-terminal. A per-message
|
|
28
|
+
* override is accepted at `enqueue`; this is the client-level default.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_MAX_ATTEMPTS = 5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default retention (ms) for terminal messages before the prune cron sweeps them:
|
|
34
|
+
* 30 days. Bounds unbounded growth of the `messages` table while leaving a
|
|
35
|
+
* generous audit window for delivery records. A host that wants a different
|
|
36
|
+
* window drives `prune` from its own scheduler with an explicit `before` cutoff.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_RETENTION_MS = 2_592_000_000;
|
|
39
|
+
|
|
40
|
+
/** Default page size for a `prune` pass before the sweep self-reschedules. */
|
|
41
|
+
export const DEFAULT_PRUNE_BATCH = 200;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional generic-SMTP transport adapter — a host-side `./smtp` entry the
|
|
3
|
+
* host imports into its OWN `"use node"` action to actually send queued messages
|
|
4
|
+
* over SMTP (Stalwart, Postfix, any server). It is NOT part of the sandboxed
|
|
5
|
+
* component: a Convex component runs in V8 and cannot open a raw SMTP socket or
|
|
6
|
+
* ship a `"use node"` action, so the real send is host-side glue.
|
|
7
|
+
*
|
|
8
|
+
* Tree-shake boundary: a backend-only consumer importing `@vllnt/convex-email`
|
|
9
|
+
* (the `.` entry) pulls ZERO SMTP code and no `nodemailer`. Importing this `./smtp`
|
|
10
|
+
* entry is the explicit opt-in; `nodemailer` is an optional peer dep loaded only
|
|
11
|
+
* by {@link createSmtpTransport} / {@link createSmtpSender} here.
|
|
12
|
+
*
|
|
13
|
+
* Two layers:
|
|
14
|
+
* - pure + 100%-covered: {@link sendViaSmtp}, {@link validateSmtpConfig},
|
|
15
|
+
* {@link toMailOptions} — driven by an injected transport, unit-tested with a fake.
|
|
16
|
+
* - thin Node wrapper (coverage-excluded): {@link createSmtpTransport},
|
|
17
|
+
* {@link createSmtpSender} — the real `nodemailer.createTransport`, consumer-E2E verified.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { sendViaSmtp, validateSmtpConfig, toMailOptions } from "./send.js";
|
|
21
|
+
export { createSmtpTransport, createSmtpSender } from "./transport.js";
|
|
22
|
+
export type {
|
|
23
|
+
SmtpConfig,
|
|
24
|
+
SmtpMessage,
|
|
25
|
+
SmtpMailOptions,
|
|
26
|
+
SmtpSendInfo,
|
|
27
|
+
SmtpSendResult,
|
|
28
|
+
SmtpSender,
|
|
29
|
+
SmtpTransport,
|
|
30
|
+
} from "./types.js";
|