@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
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildEmailCreate,
|
|
4
|
+
buildSubmitRequest,
|
|
5
|
+
createJmapSender,
|
|
6
|
+
discoverJmapSession,
|
|
7
|
+
parseSubmitResponse,
|
|
8
|
+
sendViaJmap,
|
|
9
|
+
validateJmapConfig,
|
|
10
|
+
} from "./send.js";
|
|
11
|
+
import type { JmapConfig, JmapFetch, JmapRequestInit, JmapResponse } from "./types.js";
|
|
12
|
+
|
|
13
|
+
const CONFIG: JmapConfig = {
|
|
14
|
+
endpoint: "https://mail.example.com/jmap",
|
|
15
|
+
token: "tok",
|
|
16
|
+
accountId: "acc1",
|
|
17
|
+
identityId: "id1",
|
|
18
|
+
mailboxId: "mb1",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A canned JMAP HTTP response. */
|
|
22
|
+
function res(body: unknown, init?: { ok?: boolean; status?: number }): JmapResponse {
|
|
23
|
+
return {
|
|
24
|
+
ok: init?.ok ?? true,
|
|
25
|
+
status: init?.status ?? 200,
|
|
26
|
+
json: () => Promise.resolve(body),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A fake fetch that returns canned responses in order and records the requests. */
|
|
31
|
+
function queueFetch(responses: JmapResponse[]): {
|
|
32
|
+
fetchFn: JmapFetch;
|
|
33
|
+
calls: Array<{ url: string; init: JmapRequestInit }>;
|
|
34
|
+
} {
|
|
35
|
+
const calls: Array<{ url: string; init: JmapRequestInit }> = [];
|
|
36
|
+
let i = 0;
|
|
37
|
+
const fetchFn: JmapFetch = (url, init) => {
|
|
38
|
+
calls.push({ url, init });
|
|
39
|
+
const next = responses[i];
|
|
40
|
+
i += 1;
|
|
41
|
+
return Promise.resolve(next);
|
|
42
|
+
};
|
|
43
|
+
return { fetchFn, calls };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A successful Email/set + EmailSubmission/set response body. */
|
|
47
|
+
function sendOkBody(emailId = "E1", subId = "S1"): unknown {
|
|
48
|
+
return {
|
|
49
|
+
methodResponses: [
|
|
50
|
+
["Email/set", { created: { draft: { id: emailId } } }, "0"],
|
|
51
|
+
["EmailSubmission/set", { created: { sub: { id: subId } } }, "1"],
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("validateJmapConfig", () => {
|
|
57
|
+
test("returns a fully valid config unchanged", () => {
|
|
58
|
+
expect(validateJmapConfig(CONFIG)).toBe(CONFIG);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("rejects a whitespace-only id", () => {
|
|
62
|
+
expect(() => validateJmapConfig({ ...CONFIG, endpoint: " " })).toThrow(
|
|
63
|
+
/endpoint/,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects a non-string id", () => {
|
|
68
|
+
expect(() =>
|
|
69
|
+
validateJmapConfig({ ...CONFIG, token: 123 as unknown as string }),
|
|
70
|
+
).toThrow(/token/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects each missing required id", () => {
|
|
74
|
+
expect(() => validateJmapConfig({ ...CONFIG, accountId: "" })).toThrow(
|
|
75
|
+
/accountId/,
|
|
76
|
+
);
|
|
77
|
+
expect(() => validateJmapConfig({ ...CONFIG, identityId: "" })).toThrow(
|
|
78
|
+
/identityId/,
|
|
79
|
+
);
|
|
80
|
+
expect(() => validateJmapConfig({ ...CONFIG, mailboxId: "" })).toThrow(
|
|
81
|
+
/mailboxId/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("buildEmailCreate", () => {
|
|
87
|
+
test("builds a multipart/alternative body when text and html are both set", () => {
|
|
88
|
+
const { email, envelope } = buildEmailCreate(
|
|
89
|
+
{
|
|
90
|
+
to: "to@x.com",
|
|
91
|
+
from: "from@x.com",
|
|
92
|
+
subject: "Hi",
|
|
93
|
+
text: "plain",
|
|
94
|
+
html: "<p>h</p>",
|
|
95
|
+
replyTo: "r@x.com",
|
|
96
|
+
headers: { "X-Tag": "welcome" },
|
|
97
|
+
},
|
|
98
|
+
{ mailboxId: "mb1" },
|
|
99
|
+
);
|
|
100
|
+
expect(email.mailboxIds).toEqual({ mb1: true });
|
|
101
|
+
expect(email.from).toEqual([{ email: "from@x.com" }]);
|
|
102
|
+
expect(email.to).toEqual([{ email: "to@x.com" }]);
|
|
103
|
+
expect(email.subject).toBe("Hi");
|
|
104
|
+
expect(email.replyTo).toEqual([{ email: "r@x.com" }]);
|
|
105
|
+
expect(email["header:X-Tag:asText"]).toBe("welcome");
|
|
106
|
+
const structure = email.bodyStructure as { type: string };
|
|
107
|
+
expect(structure.type).toBe("multipart/alternative");
|
|
108
|
+
expect(email.bodyValues).toEqual({
|
|
109
|
+
text: { value: "plain" },
|
|
110
|
+
html: { value: "<p>h</p>" },
|
|
111
|
+
});
|
|
112
|
+
expect(envelope).toEqual({
|
|
113
|
+
mailFrom: { email: "from@x.com" },
|
|
114
|
+
rcptTo: [{ email: "to@x.com" }],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("builds an html-only body and omits subject/replyTo/headers when absent", () => {
|
|
119
|
+
const { email } = buildEmailCreate(
|
|
120
|
+
{ to: "to@x.com", from: "f@x.com", html: "<p>h</p>" },
|
|
121
|
+
{},
|
|
122
|
+
);
|
|
123
|
+
expect((email.bodyStructure as { type: string }).type).toBe("text/html");
|
|
124
|
+
expect(email.bodyValues).toEqual({ html: { value: "<p>h</p>" } });
|
|
125
|
+
expect(email.subject).toBeUndefined();
|
|
126
|
+
expect(email.replyTo).toBeUndefined();
|
|
127
|
+
expect(email["header:X:asText"]).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("builds a text-only body", () => {
|
|
131
|
+
const { email } = buildEmailCreate(
|
|
132
|
+
{ to: "to@x.com", from: "f@x.com", text: "plain" },
|
|
133
|
+
{},
|
|
134
|
+
);
|
|
135
|
+
expect((email.bodyStructure as { type: string }).type).toBe("text/plain");
|
|
136
|
+
expect(email.bodyValues).toEqual({ text: { value: "plain" } });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("falls back to config.from and splits a comma-separated recipient list", () => {
|
|
140
|
+
const { email, envelope } = buildEmailCreate(
|
|
141
|
+
{ to: "a@x.com, b@x.com ,c@x.com", text: "x" },
|
|
142
|
+
{ from: "cfg@x.com" },
|
|
143
|
+
);
|
|
144
|
+
expect(email.from).toEqual([{ email: "cfg@x.com" }]);
|
|
145
|
+
expect(email.to).toEqual([
|
|
146
|
+
{ email: "a@x.com" },
|
|
147
|
+
{ email: "b@x.com" },
|
|
148
|
+
{ email: "c@x.com" },
|
|
149
|
+
]);
|
|
150
|
+
expect(envelope.rcptTo).toHaveLength(3);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("rejects an empty or non-string `to`", () => {
|
|
154
|
+
expect(() => buildEmailCreate({ to: "", text: "x" }, { from: "f@x.com" })).toThrow(
|
|
155
|
+
/`to`/,
|
|
156
|
+
);
|
|
157
|
+
expect(() =>
|
|
158
|
+
buildEmailCreate({ to: 5 as unknown as string, text: "x" }, { from: "f@x.com" }),
|
|
159
|
+
).toThrow(/`to`/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("rejects a missing from (neither message nor config)", () => {
|
|
163
|
+
expect(() => buildEmailCreate({ to: "t@x.com", text: "x" }, {})).toThrow(/`from`/);
|
|
164
|
+
expect(() =>
|
|
165
|
+
buildEmailCreate({ to: "t@x.com", from: " ", text: "x" }, {}),
|
|
166
|
+
).toThrow(/`from`/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("rejects a body with neither text nor html", () => {
|
|
170
|
+
expect(() => buildEmailCreate({ to: "t@x.com", from: "f@x.com" }, {})).toThrow(
|
|
171
|
+
/text.*html/,
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("rejects a recipient list that resolves to no addresses", () => {
|
|
176
|
+
expect(() =>
|
|
177
|
+
buildEmailCreate({ to: " , , ", from: "f@x.com", text: "x" }, {}),
|
|
178
|
+
).toThrow(/at least one address/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("rejects CRLF injection in from, to, replyTo, subject, and headers", () => {
|
|
182
|
+
const base = { to: "t@x.com", from: "f@x.com", text: "x" };
|
|
183
|
+
expect(() => buildEmailCreate({ ...base, from: "f@x.com\r\nX" }, {})).toThrow(
|
|
184
|
+
/`from`/,
|
|
185
|
+
);
|
|
186
|
+
expect(() => buildEmailCreate({ ...base, to: "t@x.com\nBcc: e" }, {})).toThrow(
|
|
187
|
+
/`to`/,
|
|
188
|
+
);
|
|
189
|
+
expect(() =>
|
|
190
|
+
buildEmailCreate({ ...base, replyTo: "r@x.com\rX" }, {}),
|
|
191
|
+
).toThrow(/`replyTo`/);
|
|
192
|
+
expect(() =>
|
|
193
|
+
buildEmailCreate({ ...base, subject: "Hi\r\nInjected" }, {}),
|
|
194
|
+
).toThrow(/`subject`/);
|
|
195
|
+
expect(() =>
|
|
196
|
+
buildEmailCreate({ ...base, headers: { "X-A\nB": "v" } }, {}),
|
|
197
|
+
).toThrow(/headers/);
|
|
198
|
+
expect(() =>
|
|
199
|
+
buildEmailCreate({ ...base, headers: { "X-A": "v\r\nC" } }, {}),
|
|
200
|
+
).toThrow(/headers/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("buildSubmitRequest", () => {
|
|
205
|
+
test("assembles the Email/set + EmailSubmission/set two-call batch", () => {
|
|
206
|
+
const req = buildSubmitRequest(
|
|
207
|
+
{ to: "to@x.com", from: "f@x.com", html: "<p>h</p>" },
|
|
208
|
+
CONFIG,
|
|
209
|
+
);
|
|
210
|
+
expect(req.using).toEqual([
|
|
211
|
+
"urn:ietf:params:jmap:core",
|
|
212
|
+
"urn:ietf:params:jmap:mail",
|
|
213
|
+
"urn:ietf:params:jmap:submission",
|
|
214
|
+
]);
|
|
215
|
+
const [emailCall, subCall] = req.methodCalls as Array<
|
|
216
|
+
[string, Record<string, unknown>, string]
|
|
217
|
+
>;
|
|
218
|
+
expect(emailCall[0]).toBe("Email/set");
|
|
219
|
+
expect(emailCall[1].accountId).toBe("acc1");
|
|
220
|
+
expect(subCall[0]).toBe("EmailSubmission/set");
|
|
221
|
+
const create = subCall[1].create as { sub: { emailId: string; identityId: string } };
|
|
222
|
+
expect(create.sub.emailId).toBe("#draft");
|
|
223
|
+
expect(create.sub.identityId).toBe("id1");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("parseSubmitResponse", () => {
|
|
228
|
+
test("returns the email + submission ids on success", () => {
|
|
229
|
+
expect(parseSubmitResponse(sendOkBody("E9", "S9"))).toEqual({
|
|
230
|
+
messageId: "S9",
|
|
231
|
+
emailId: "E9",
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("ignores a non-array entry and a same-name non-record invocation", () => {
|
|
236
|
+
const body = {
|
|
237
|
+
methodResponses: [
|
|
238
|
+
42,
|
|
239
|
+
["Email/set", null, "0"],
|
|
240
|
+
["Email/set", { created: { draft: { id: "E1" } } }, "0b"],
|
|
241
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
expect(parseSubmitResponse(body)).toEqual({ messageId: "S1", emailId: "E1" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("throws on a non-object response", () => {
|
|
248
|
+
expect(() => parseSubmitResponse(null)).toThrow(/malformed/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("throws when methodResponses is missing", () => {
|
|
252
|
+
expect(() => parseSubmitResponse({})).toThrow(/malformed/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("throws on a method-level error (with and without a type)", () => {
|
|
256
|
+
expect(() =>
|
|
257
|
+
parseSubmitResponse({ methodResponses: [["error", { type: "unknownMethod" }, "0"]] }),
|
|
258
|
+
).toThrow(/unknownMethod/);
|
|
259
|
+
expect(() =>
|
|
260
|
+
parseSubmitResponse({ methodResponses: [["error", {}, "0"]] }),
|
|
261
|
+
).toThrow(/unknown/);
|
|
262
|
+
expect(() =>
|
|
263
|
+
parseSubmitResponse({ methodResponses: [["error", null, "0"]] }),
|
|
264
|
+
).toThrow(/unknown/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("throws when Email/set or EmailSubmission/set is missing", () => {
|
|
268
|
+
expect(() =>
|
|
269
|
+
parseSubmitResponse({
|
|
270
|
+
methodResponses: [["Email/set", { created: { draft: { id: "E1" } } }, "0"]],
|
|
271
|
+
}),
|
|
272
|
+
).toThrow(/missing Email\/set or EmailSubmission\/set/);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("throws when the Email was notCreated (with and without a SetError type)", () => {
|
|
276
|
+
expect(() =>
|
|
277
|
+
parseSubmitResponse({
|
|
278
|
+
methodResponses: [
|
|
279
|
+
["Email/set", { notCreated: { draft: { type: "tooLarge" } } }, "0"],
|
|
280
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
281
|
+
],
|
|
282
|
+
}),
|
|
283
|
+
).toThrow(/Email not created \(tooLarge\)/);
|
|
284
|
+
expect(() =>
|
|
285
|
+
parseSubmitResponse({
|
|
286
|
+
methodResponses: [
|
|
287
|
+
["Email/set", { notCreated: { draft: {} } }, "0"],
|
|
288
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
289
|
+
],
|
|
290
|
+
}),
|
|
291
|
+
).toThrow(/Email not created \(unknown\)/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("throws when the EmailSubmission was notCreated", () => {
|
|
295
|
+
expect(() =>
|
|
296
|
+
parseSubmitResponse({
|
|
297
|
+
methodResponses: [
|
|
298
|
+
["Email/set", { created: { draft: { id: "E1" } } }, "0"],
|
|
299
|
+
["EmailSubmission/set", { notCreated: { sub: { type: "forbidden" } } }, "1"],
|
|
300
|
+
],
|
|
301
|
+
}),
|
|
302
|
+
).toThrow(/EmailSubmission not created \(forbidden\)/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("throws when a created entry has no id, is not an object, or created is absent", () => {
|
|
306
|
+
expect(() =>
|
|
307
|
+
parseSubmitResponse({
|
|
308
|
+
methodResponses: [
|
|
309
|
+
["Email/set", { created: { draft: {} } }, "0"],
|
|
310
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
311
|
+
],
|
|
312
|
+
}),
|
|
313
|
+
).toThrow(/Email not created \(no id/);
|
|
314
|
+
expect(() =>
|
|
315
|
+
parseSubmitResponse({
|
|
316
|
+
methodResponses: [
|
|
317
|
+
["Email/set", { created: { draft: 5 } }, "0"],
|
|
318
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
319
|
+
],
|
|
320
|
+
}),
|
|
321
|
+
).toThrow(/Email not created \(no id/);
|
|
322
|
+
expect(() =>
|
|
323
|
+
parseSubmitResponse({
|
|
324
|
+
methodResponses: [
|
|
325
|
+
["Email/set", {}, "0"],
|
|
326
|
+
["EmailSubmission/set", { created: { sub: { id: "S1" } } }, "1"],
|
|
327
|
+
],
|
|
328
|
+
}),
|
|
329
|
+
).toThrow(/Email not created \(no id/);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("sendViaJmap (injected fetch)", () => {
|
|
334
|
+
test("POSTs the batch with a bearer token and returns the ids", async () => {
|
|
335
|
+
const { fetchFn, calls } = queueFetch([res(sendOkBody("E1", "S1"))]);
|
|
336
|
+
const result = await sendViaJmap(
|
|
337
|
+
fetchFn,
|
|
338
|
+
{ to: "to@x.com", from: "f@x.com", html: "<p>h</p>" },
|
|
339
|
+
CONFIG,
|
|
340
|
+
);
|
|
341
|
+
expect(result).toEqual({ messageId: "S1", emailId: "E1" });
|
|
342
|
+
expect(calls).toHaveLength(1);
|
|
343
|
+
expect(calls[0].url).toBe(CONFIG.endpoint);
|
|
344
|
+
expect(calls[0].init.method).toBe("POST");
|
|
345
|
+
expect(calls[0].init.headers.Authorization).toBe("Bearer tok");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("throws on a non-2xx HTTP status", async () => {
|
|
349
|
+
const { fetchFn } = queueFetch([res(null, { ok: false, status: 401 })]);
|
|
350
|
+
await expect(
|
|
351
|
+
sendViaJmap(fetchFn, { to: "t@x.com", from: "f@x.com", text: "x" }, CONFIG),
|
|
352
|
+
).rejects.toThrow(/HTTP 401/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("rejects an invalid config before any fetch", async () => {
|
|
356
|
+
const { fetchFn, calls } = queueFetch([res(sendOkBody())]);
|
|
357
|
+
await expect(
|
|
358
|
+
sendViaJmap(fetchFn, { to: "t@x.com", from: "f@x.com", text: "x" }, {
|
|
359
|
+
...CONFIG,
|
|
360
|
+
endpoint: "",
|
|
361
|
+
}),
|
|
362
|
+
).rejects.toThrow(/endpoint/);
|
|
363
|
+
expect(calls).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("rejects an invalid message before any fetch", async () => {
|
|
367
|
+
const { fetchFn, calls } = queueFetch([res(sendOkBody())]);
|
|
368
|
+
await expect(
|
|
369
|
+
sendViaJmap(fetchFn, { to: "", from: "f@x.com", text: "x" }, CONFIG),
|
|
370
|
+
).rejects.toThrow(/`to`/);
|
|
371
|
+
expect(calls).toHaveLength(0);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("discoverJmapSession", () => {
|
|
376
|
+
const sessionUrl = "https://mail.example.com/.well-known/jmap";
|
|
377
|
+
const sessionOk = () =>
|
|
378
|
+
res({
|
|
379
|
+
apiUrl: "https://mail.example.com/jmap",
|
|
380
|
+
primaryAccounts: { "urn:ietf:params:jmap:mail": "acc1" },
|
|
381
|
+
});
|
|
382
|
+
const identitiesOk = () =>
|
|
383
|
+
res({
|
|
384
|
+
methodResponses: [
|
|
385
|
+
[
|
|
386
|
+
"Identity/get",
|
|
387
|
+
{
|
|
388
|
+
list: [
|
|
389
|
+
{ id: "idA", email: "a@x.com" },
|
|
390
|
+
{ id: "idB", email: "b@x.com" },
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
"0",
|
|
394
|
+
],
|
|
395
|
+
],
|
|
396
|
+
});
|
|
397
|
+
const mailboxesBody = (list: unknown) => res({ methodResponses: [["Mailbox/get", { list }, "0"]] });
|
|
398
|
+
|
|
399
|
+
test("resolves endpoint, account, sent mailbox, and identity by `from`", async () => {
|
|
400
|
+
const { fetchFn } = queueFetch([
|
|
401
|
+
sessionOk(),
|
|
402
|
+
identitiesOk(),
|
|
403
|
+
mailboxesBody([
|
|
404
|
+
{ id: "mbInbox", role: "inbox" },
|
|
405
|
+
{ id: "mbSent", role: "sent" },
|
|
406
|
+
]),
|
|
407
|
+
]);
|
|
408
|
+
const cfg = await discoverJmapSession(fetchFn, {
|
|
409
|
+
sessionUrl,
|
|
410
|
+
token: "tok",
|
|
411
|
+
from: "b@x.com",
|
|
412
|
+
});
|
|
413
|
+
expect(cfg).toEqual({
|
|
414
|
+
endpoint: "https://mail.example.com/jmap",
|
|
415
|
+
token: "tok",
|
|
416
|
+
accountId: "acc1",
|
|
417
|
+
identityId: "idB",
|
|
418
|
+
mailboxId: "mbSent",
|
|
419
|
+
from: "b@x.com",
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("falls back to the first identity and its email when no `from` is given", async () => {
|
|
424
|
+
const { fetchFn } = queueFetch([
|
|
425
|
+
sessionOk(),
|
|
426
|
+
identitiesOk(),
|
|
427
|
+
mailboxesBody([{ id: "mbDrafts", role: "drafts" }]),
|
|
428
|
+
]);
|
|
429
|
+
const cfg = await discoverJmapSession(fetchFn, { sessionUrl, token: "tok" });
|
|
430
|
+
expect(cfg.identityId).toBe("idA");
|
|
431
|
+
expect(cfg.from).toBe("a@x.com");
|
|
432
|
+
expect(cfg.mailboxId).toBe("mbDrafts");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("uses the first identity when `from` matches none, and the first mailbox with no roles", async () => {
|
|
436
|
+
const { fetchFn } = queueFetch([
|
|
437
|
+
sessionOk(),
|
|
438
|
+
identitiesOk(),
|
|
439
|
+
mailboxesBody([{ id: "mbFirst" }, { id: "mbSecond", role: "archive" }]),
|
|
440
|
+
]);
|
|
441
|
+
const cfg = await discoverJmapSession(fetchFn, {
|
|
442
|
+
sessionUrl,
|
|
443
|
+
token: "tok",
|
|
444
|
+
from: "nomatch@x.com",
|
|
445
|
+
});
|
|
446
|
+
expect(cfg.identityId).toBe("idA");
|
|
447
|
+
expect(cfg.mailboxId).toBe("mbFirst");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("rejects an empty sessionUrl or token", async () => {
|
|
451
|
+
const { fetchFn } = queueFetch([]);
|
|
452
|
+
await expect(
|
|
453
|
+
discoverJmapSession(fetchFn, { sessionUrl: "", token: "tok" }),
|
|
454
|
+
).rejects.toThrow(/sessionUrl/);
|
|
455
|
+
await expect(
|
|
456
|
+
discoverJmapSession(fetchFn, { sessionUrl, token: " " }),
|
|
457
|
+
).rejects.toThrow(/token/);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("throws on a failed session request", async () => {
|
|
461
|
+
const { fetchFn } = queueFetch([res(null, { ok: false, status: 403 })]);
|
|
462
|
+
await expect(
|
|
463
|
+
discoverJmapSession(fetchFn, { sessionUrl, token: "tok" }),
|
|
464
|
+
).rejects.toThrow(/session request failed \(HTTP 403\)/);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("throws when the session has no apiUrl", async () => {
|
|
468
|
+
const { fetchFn: f1 } = queueFetch([res({})]);
|
|
469
|
+
await expect(
|
|
470
|
+
discoverJmapSession(f1, { sessionUrl, token: "tok" }),
|
|
471
|
+
).rejects.toThrow(/no apiUrl/);
|
|
472
|
+
const { fetchFn: f2 } = queueFetch([res(null)]);
|
|
473
|
+
await expect(
|
|
474
|
+
discoverJmapSession(f2, { sessionUrl, token: "tok" }),
|
|
475
|
+
).rejects.toThrow(/no apiUrl/);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("throws when there is no primary mail account (missing or empty)", async () => {
|
|
479
|
+
const { fetchFn: f1 } = queueFetch([res({ apiUrl: "u", primaryAccounts: {} })]);
|
|
480
|
+
await expect(
|
|
481
|
+
discoverJmapSession(f1, { sessionUrl, token: "tok" }),
|
|
482
|
+
).rejects.toThrow(/no primary mail account/);
|
|
483
|
+
const { fetchFn: f2 } = queueFetch([res({ apiUrl: "u" })]);
|
|
484
|
+
await expect(
|
|
485
|
+
discoverJmapSession(f2, { sessionUrl, token: "tok" }),
|
|
486
|
+
).rejects.toThrow(/no primary mail account/);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("throws on a failed Identity/get HTTP request", async () => {
|
|
490
|
+
const { fetchFn } = queueFetch([sessionOk(), res(null, { ok: false, status: 500 })]);
|
|
491
|
+
await expect(
|
|
492
|
+
discoverJmapSession(fetchFn, { sessionUrl, token: "tok" }),
|
|
493
|
+
).rejects.toThrow(/HTTP 500/);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("throws when no identity is found (missing invocation or non-array list)", async () => {
|
|
497
|
+
const { fetchFn: f1 } = queueFetch([
|
|
498
|
+
sessionOk(),
|
|
499
|
+
res({ methodResponses: [["Other/get", { list: [] }, "0"]] }),
|
|
500
|
+
]);
|
|
501
|
+
await expect(
|
|
502
|
+
discoverJmapSession(f1, { sessionUrl, token: "tok" }),
|
|
503
|
+
).rejects.toThrow(/no sending identity/);
|
|
504
|
+
const { fetchFn: f2 } = queueFetch([
|
|
505
|
+
sessionOk(),
|
|
506
|
+
res({ methodResponses: [["Identity/get", { list: "nope" }, "0"]] }),
|
|
507
|
+
]);
|
|
508
|
+
await expect(
|
|
509
|
+
discoverJmapSession(f2, { sessionUrl, token: "tok" }),
|
|
510
|
+
).rejects.toThrow(/no sending identity/);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("throws on a malformed Mailbox/get response and when no mailbox is found", async () => {
|
|
514
|
+
const { fetchFn: f1 } = queueFetch([sessionOk(), identitiesOk(), res({})]);
|
|
515
|
+
await expect(
|
|
516
|
+
discoverJmapSession(f1, { sessionUrl, token: "tok" }),
|
|
517
|
+
).rejects.toThrow(/malformed/);
|
|
518
|
+
const { fetchFn: f2 } = queueFetch([sessionOk(), identitiesOk(), mailboxesBody([])]);
|
|
519
|
+
await expect(
|
|
520
|
+
discoverJmapSession(f2, { sessionUrl, token: "tok" }),
|
|
521
|
+
).rejects.toThrow(/no sent or drafts mailbox/);
|
|
522
|
+
const { fetchFn: f3 } = queueFetch([
|
|
523
|
+
sessionOk(),
|
|
524
|
+
identitiesOk(),
|
|
525
|
+
res({ methodResponses: [["Mailbox/get", { list: "nope" }, "0"]] }),
|
|
526
|
+
]);
|
|
527
|
+
await expect(
|
|
528
|
+
discoverJmapSession(f3, { sessionUrl, token: "tok" }),
|
|
529
|
+
).rejects.toThrow(/no sent or drafts mailbox/);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("skips malformed identity and mailbox list entries", async () => {
|
|
533
|
+
const { fetchFn } = queueFetch([
|
|
534
|
+
sessionOk(),
|
|
535
|
+
res({
|
|
536
|
+
methodResponses: [
|
|
537
|
+
[
|
|
538
|
+
"Identity/get",
|
|
539
|
+
{ list: [42, { id: "idA" }, { id: "idOk", email: "ok@x.com" }] },
|
|
540
|
+
"0",
|
|
541
|
+
],
|
|
542
|
+
],
|
|
543
|
+
}),
|
|
544
|
+
mailboxesBody([99, { role: "sent" }, { id: "mbReal", role: "sent" }]),
|
|
545
|
+
]);
|
|
546
|
+
const cfg = await discoverJmapSession(fetchFn, { sessionUrl, token: "tok" });
|
|
547
|
+
expect(cfg.identityId).toBe("idOk");
|
|
548
|
+
expect(cfg.mailboxId).toBe("mbReal");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("createJmapSender", () => {
|
|
553
|
+
test("binds the config + fetch and sends one message", async () => {
|
|
554
|
+
const { fetchFn, calls } = queueFetch([res(sendOkBody("E1", "S1"))]);
|
|
555
|
+
const send = createJmapSender(CONFIG, fetchFn);
|
|
556
|
+
const result = await send({ to: "to@x.com", from: "f@x.com", html: "<p>h</p>" });
|
|
557
|
+
expect(result).toEqual({ messageId: "S1", emailId: "E1" });
|
|
558
|
+
expect(calls).toHaveLength(1);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("throws eagerly on an invalid config", () => {
|
|
562
|
+
const { fetchFn } = queueFetch([]);
|
|
563
|
+
expect(() => createJmapSender({ ...CONFIG, token: "" }, fetchFn)).toThrow(/token/);
|
|
564
|
+
});
|
|
565
|
+
});
|