@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,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { sendViaSmtp, toMailOptions, validateSmtpConfig } from "./send.js";
|
|
3
|
+
import type { SmtpMailOptions, SmtpSendInfo, SmtpTransport } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/** A fake transport: records the options it received and returns a canned info. */
|
|
6
|
+
function fakeTransport(info: SmtpSendInfo): {
|
|
7
|
+
transport: SmtpTransport;
|
|
8
|
+
calls: SmtpMailOptions[];
|
|
9
|
+
} {
|
|
10
|
+
const calls: SmtpMailOptions[] = [];
|
|
11
|
+
const transport: SmtpTransport = {
|
|
12
|
+
sendMail: (options) => {
|
|
13
|
+
calls.push(options);
|
|
14
|
+
return Promise.resolve(info);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
return { calls, transport };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("validateSmtpConfig", () => {
|
|
21
|
+
test("resolves secure=true for port 465 by default", () => {
|
|
22
|
+
const c = validateSmtpConfig({ host: "smtp.example.com", port: 465 });
|
|
23
|
+
expect(c.secure).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolves secure=false for a non-465 port by default", () => {
|
|
27
|
+
const c = validateSmtpConfig({ host: "smtp.example.com", port: 587 });
|
|
28
|
+
expect(c.secure).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("honors an explicit secure flag over the port default", () => {
|
|
32
|
+
expect(
|
|
33
|
+
validateSmtpConfig({ host: "h", port: 465, secure: false }).secure,
|
|
34
|
+
).toBe(false);
|
|
35
|
+
expect(
|
|
36
|
+
validateSmtpConfig({ host: "h", port: 587, secure: true }).secure,
|
|
37
|
+
).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("accepts a valid auth block and passes it through", () => {
|
|
41
|
+
const c = validateSmtpConfig({
|
|
42
|
+
host: "h",
|
|
43
|
+
port: 587,
|
|
44
|
+
auth: { user: "u", pass: "p" },
|
|
45
|
+
});
|
|
46
|
+
expect(c.auth).toEqual({ user: "u", pass: "p" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("rejects an empty or non-string host", () => {
|
|
50
|
+
expect(() => validateSmtpConfig({ host: "", port: 25 })).toThrow(/host/);
|
|
51
|
+
expect(() => validateSmtpConfig({ host: " ", port: 25 })).toThrow(/host/);
|
|
52
|
+
expect(() =>
|
|
53
|
+
validateSmtpConfig({ host: 123 as unknown as string, port: 25 }),
|
|
54
|
+
).toThrow(/host/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects an out-of-range, non-integer, or non-number port", () => {
|
|
58
|
+
expect(() => validateSmtpConfig({ host: "h", port: 0 })).toThrow(/port/);
|
|
59
|
+
expect(() => validateSmtpConfig({ host: "h", port: 70000 })).toThrow(/port/);
|
|
60
|
+
expect(() => validateSmtpConfig({ host: "h", port: 5.5 })).toThrow(/port/);
|
|
61
|
+
expect(() =>
|
|
62
|
+
validateSmtpConfig({ host: "h", port: "25" as unknown as number }),
|
|
63
|
+
).toThrow(/port/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rejects an auth block with non-string credentials", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
validateSmtpConfig({
|
|
69
|
+
host: "h",
|
|
70
|
+
port: 25,
|
|
71
|
+
auth: { user: 1 as unknown as string, pass: "p" },
|
|
72
|
+
}),
|
|
73
|
+
).toThrow(/auth/);
|
|
74
|
+
expect(() =>
|
|
75
|
+
validateSmtpConfig({
|
|
76
|
+
host: "h",
|
|
77
|
+
port: 25,
|
|
78
|
+
auth: { user: "u", pass: 2 as unknown as string },
|
|
79
|
+
}),
|
|
80
|
+
).toThrow(/auth/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("toMailOptions", () => {
|
|
85
|
+
test("maps every field and uses message.from when present", () => {
|
|
86
|
+
const opts = toMailOptions(
|
|
87
|
+
{
|
|
88
|
+
to: "to@x.com",
|
|
89
|
+
from: "msg@x.com",
|
|
90
|
+
subject: "Hi",
|
|
91
|
+
text: "t",
|
|
92
|
+
html: "<p>h</p>",
|
|
93
|
+
replyTo: "r@x.com",
|
|
94
|
+
headers: { "X-Tag": "welcome" },
|
|
95
|
+
},
|
|
96
|
+
{ from: "cfg@x.com" },
|
|
97
|
+
);
|
|
98
|
+
expect(opts).toEqual({
|
|
99
|
+
to: "to@x.com",
|
|
100
|
+
from: "msg@x.com",
|
|
101
|
+
subject: "Hi",
|
|
102
|
+
text: "t",
|
|
103
|
+
html: "<p>h</p>",
|
|
104
|
+
replyTo: "r@x.com",
|
|
105
|
+
headers: { "X-Tag": "welcome" },
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("falls back to config.from when the message omits from", () => {
|
|
110
|
+
const opts = toMailOptions({ to: "to@x.com", text: "t" }, { from: "cfg@x.com" });
|
|
111
|
+
expect(opts.from).toBe("cfg@x.com");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects an empty or non-string `to`", () => {
|
|
115
|
+
expect(() => toMailOptions({ to: "", text: "t" }, {})).toThrow(/`to`/);
|
|
116
|
+
expect(() =>
|
|
117
|
+
toMailOptions({ to: 5 as unknown as string, text: "t" }, {}),
|
|
118
|
+
).toThrow(/`to`/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects a missing from (neither message nor config supplies one)", () => {
|
|
122
|
+
expect(() => toMailOptions({ to: "to@x.com", text: "t" }, {})).toThrow(/`from`/);
|
|
123
|
+
expect(() =>
|
|
124
|
+
toMailOptions({ to: "to@x.com", from: " ", text: "t" }, {}),
|
|
125
|
+
).toThrow(/`from`/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("rejects a body with neither text nor html", () => {
|
|
129
|
+
expect(() => toMailOptions({ to: "to@x.com", from: "f@x.com" }, {})).toThrow(
|
|
130
|
+
/text.*html/,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("accepts an html-only and a text-only body", () => {
|
|
135
|
+
expect(
|
|
136
|
+
toMailOptions({ to: "t@x.com", from: "f@x.com", html: "<p>x</p>" }, {}).html,
|
|
137
|
+
).toBe("<p>x</p>");
|
|
138
|
+
expect(
|
|
139
|
+
toMailOptions({ to: "t@x.com", from: "f@x.com", text: "x" }, {}).text,
|
|
140
|
+
).toBe("x");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("rejects CRLF injection in to, from, replyTo, subject, and headers", () => {
|
|
144
|
+
const base = { to: "t@x.com", from: "f@x.com", text: "x" };
|
|
145
|
+
expect(() => toMailOptions({ ...base, to: "t@x.com\r\nBCC: e" }, {})).toThrow(
|
|
146
|
+
/`to`/,
|
|
147
|
+
);
|
|
148
|
+
expect(() =>
|
|
149
|
+
toMailOptions({ ...base, from: "f@x.com\nDATA" }, {}),
|
|
150
|
+
).toThrow(/`from`/);
|
|
151
|
+
expect(() =>
|
|
152
|
+
toMailOptions({ ...base, replyTo: "r@x.com\rX" }, {}),
|
|
153
|
+
).toThrow(/`replyTo`/);
|
|
154
|
+
expect(() =>
|
|
155
|
+
toMailOptions({ ...base, subject: "Hi\r\nInjected" }, {}),
|
|
156
|
+
).toThrow(/`subject`/);
|
|
157
|
+
expect(() =>
|
|
158
|
+
toMailOptions({ ...base, headers: { "X-A\nB": "v" } }, {}),
|
|
159
|
+
).toThrow(/headers/);
|
|
160
|
+
expect(() =>
|
|
161
|
+
toMailOptions({ ...base, headers: { "X-A": "v\r\nC" } }, {}),
|
|
162
|
+
).toThrow(/headers/);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("accepts a clean message with optional fields omitted", () => {
|
|
166
|
+
const opts = toMailOptions({ to: "t@x.com", from: "f@x.com", text: "x" }, {});
|
|
167
|
+
expect(opts.subject).toBeUndefined();
|
|
168
|
+
expect(opts.replyTo).toBeUndefined();
|
|
169
|
+
expect(opts.headers).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("sendViaSmtp (injected transport)", () => {
|
|
174
|
+
test("sends through the transport and normalizes the result", async () => {
|
|
175
|
+
const { transport, calls } = fakeTransport({
|
|
176
|
+
messageId: "<smtp-1@server>",
|
|
177
|
+
accepted: ["to@x.com"],
|
|
178
|
+
rejected: [],
|
|
179
|
+
});
|
|
180
|
+
const result = await sendViaSmtp(
|
|
181
|
+
transport,
|
|
182
|
+
{ to: "to@x.com", subject: "Hi", html: "<p>x</p>" },
|
|
183
|
+
{ from: "no-reply@app.com" },
|
|
184
|
+
);
|
|
185
|
+
expect(result).toEqual({
|
|
186
|
+
messageId: "<smtp-1@server>",
|
|
187
|
+
accepted: ["to@x.com"],
|
|
188
|
+
rejected: [],
|
|
189
|
+
});
|
|
190
|
+
expect(calls).toHaveLength(1);
|
|
191
|
+
expect(calls[0].from).toBe("no-reply@app.com");
|
|
192
|
+
expect(calls[0].to).toBe("to@x.com");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("flattens object-form accepted/rejected address entries", async () => {
|
|
196
|
+
const { transport } = fakeTransport({
|
|
197
|
+
messageId: "<id>",
|
|
198
|
+
accepted: [{ address: "ok@x.com" }, "two@x.com"],
|
|
199
|
+
rejected: [{ address: "no@x.com" }],
|
|
200
|
+
});
|
|
201
|
+
const result = await sendViaSmtp(
|
|
202
|
+
transport,
|
|
203
|
+
{ to: "ok@x.com", from: "f@x.com", text: "x" },
|
|
204
|
+
);
|
|
205
|
+
expect(result.accepted).toEqual(["ok@x.com", "two@x.com"]);
|
|
206
|
+
expect(result.rejected).toEqual(["no@x.com"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("defaults messageId to '' and accepted/rejected to [] when absent", async () => {
|
|
210
|
+
const { transport } = fakeTransport({});
|
|
211
|
+
const result = await sendViaSmtp(
|
|
212
|
+
transport,
|
|
213
|
+
{ to: "t@x.com", from: "f@x.com", text: "x" },
|
|
214
|
+
);
|
|
215
|
+
expect(result).toEqual({ messageId: "", accepted: [], rejected: [] });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("uses the default empty config when none is passed", async () => {
|
|
219
|
+
const { transport, calls } = fakeTransport({ messageId: "x" });
|
|
220
|
+
await sendViaSmtp(transport, { to: "t@x.com", from: "msg@x.com", text: "x" });
|
|
221
|
+
expect(calls[0].from).toBe("msg@x.com");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("propagates a transport send failure to the caller", async () => {
|
|
225
|
+
const transport: SmtpTransport = {
|
|
226
|
+
sendMail: () => Promise.reject(new Error("connection refused")),
|
|
227
|
+
};
|
|
228
|
+
await expect(
|
|
229
|
+
sendViaSmtp(transport, { to: "t@x.com", from: "f@x.com", text: "x" }),
|
|
230
|
+
).rejects.toThrow(/connection refused/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rejects an invalid message before touching the transport", async () => {
|
|
234
|
+
const { transport, calls } = fakeTransport({ messageId: "x" });
|
|
235
|
+
await expect(
|
|
236
|
+
sendViaSmtp(transport, { to: "", from: "f@x.com", text: "x" }),
|
|
237
|
+
).rejects.toThrow(/`to`/);
|
|
238
|
+
expect(calls).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
package/src/smtp/send.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, injectable generic-SMTP send logic. Everything here is runtime-neutral
|
|
3
|
+
* and network-free: {@link sendViaSmtp} drives an injected {@link SmtpTransport},
|
|
4
|
+
* so a fake transport gives full coverage with no socket. The only piece that
|
|
5
|
+
* needs Node is the thin real-`nodemailer` wrapper in `./transport` (excluded
|
|
6
|
+
* from coverage, consumer-E2E verified).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type {
|
|
10
|
+
SmtpConfig,
|
|
11
|
+
SmtpMailOptions,
|
|
12
|
+
SmtpMessage,
|
|
13
|
+
SmtpSendInfo,
|
|
14
|
+
SmtpSendResult,
|
|
15
|
+
SmtpTransport,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
/** A control character (CR/LF) that must never reach a raw SMTP header. */
|
|
19
|
+
const CRLF = /[\r\n]/;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate and normalize a host-supplied {@link SmtpConfig}, returning a config
|
|
23
|
+
* with `secure` resolved (defaults to `true` for port 465, else `false`). Throws
|
|
24
|
+
* a plain `Error` on an invalid config — the host surfaces it. Pure: no I/O.
|
|
25
|
+
*
|
|
26
|
+
* @param config - The raw host SMTP config.
|
|
27
|
+
* @returns The same config with `secure` resolved to a concrete boolean.
|
|
28
|
+
*/
|
|
29
|
+
export function validateSmtpConfig(
|
|
30
|
+
config: SmtpConfig,
|
|
31
|
+
): SmtpConfig & { secure: boolean } {
|
|
32
|
+
if (typeof config.host !== "string" || config.host.trim() === "") {
|
|
33
|
+
throw new Error("smtp config: `host` must be a non-empty string");
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
typeof config.port !== "number" ||
|
|
37
|
+
!Number.isInteger(config.port) ||
|
|
38
|
+
config.port <= 0 ||
|
|
39
|
+
config.port > 65535
|
|
40
|
+
) {
|
|
41
|
+
throw new Error("smtp config: `port` must be an integer in 1..65535");
|
|
42
|
+
}
|
|
43
|
+
if (config.auth !== undefined) {
|
|
44
|
+
if (
|
|
45
|
+
typeof config.auth.user !== "string" ||
|
|
46
|
+
typeof config.auth.pass !== "string"
|
|
47
|
+
) {
|
|
48
|
+
throw new Error("smtp config: `auth` requires string `user` and `pass`");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const secure = config.secure ?? config.port === 465;
|
|
52
|
+
return { ...config, secure };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Guard a single address-like header value against CRLF injection. */
|
|
56
|
+
function assertNoCrlf(label: string, value: string): void {
|
|
57
|
+
if (CRLF.test(value)) {
|
|
58
|
+
throw new Error(`smtp message: \`${label}\` must not contain CR or LF`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the transport `sendMail` options from a {@link SmtpMessage} and the
|
|
64
|
+
* resolved config, resolving `from` (message → config default) and guarding the
|
|
65
|
+
* address/subject/header fields against SMTP header (CRLF) injection. Pure.
|
|
66
|
+
*
|
|
67
|
+
* @param message - The outbound message.
|
|
68
|
+
* @param config - The resolved SMTP config (for the `from` default).
|
|
69
|
+
* @returns The mail options to hand to {@link SmtpTransport.sendMail}.
|
|
70
|
+
*/
|
|
71
|
+
export function toMailOptions(
|
|
72
|
+
message: SmtpMessage,
|
|
73
|
+
config: Pick<SmtpConfig, "from">,
|
|
74
|
+
): SmtpMailOptions {
|
|
75
|
+
if (typeof message.to !== "string" || message.to.trim() === "") {
|
|
76
|
+
throw new Error("smtp message: `to` must be a non-empty string");
|
|
77
|
+
}
|
|
78
|
+
const from = message.from ?? config.from;
|
|
79
|
+
if (from === undefined || from.trim() === "") {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"smtp message: `from` is required (pass `message.from` or `config.from`)",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (message.text === undefined && message.html === undefined) {
|
|
85
|
+
throw new Error("smtp message: one of `text` or `html` is required");
|
|
86
|
+
}
|
|
87
|
+
assertNoCrlf("to", message.to);
|
|
88
|
+
assertNoCrlf("from", from);
|
|
89
|
+
if (message.replyTo !== undefined) {
|
|
90
|
+
assertNoCrlf("replyTo", message.replyTo);
|
|
91
|
+
}
|
|
92
|
+
if (message.subject !== undefined) {
|
|
93
|
+
assertNoCrlf("subject", message.subject);
|
|
94
|
+
}
|
|
95
|
+
if (message.headers !== undefined) {
|
|
96
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
97
|
+
assertNoCrlf(`headers.${key}`, key);
|
|
98
|
+
assertNoCrlf(`headers.${key}`, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
to: message.to,
|
|
103
|
+
from,
|
|
104
|
+
subject: message.subject,
|
|
105
|
+
text: message.text,
|
|
106
|
+
html: message.html,
|
|
107
|
+
replyTo: message.replyTo,
|
|
108
|
+
headers: message.headers,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Flatten a nodemailer address entry (string or `{ address }`) to a string. */
|
|
113
|
+
function addressOf(entry: string | { address: string }): string {
|
|
114
|
+
return typeof entry === "string" ? entry : entry.address;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Normalize a transport's raw send info into the public {@link SmtpSendResult}. */
|
|
118
|
+
function toSendResult(info: SmtpSendInfo): SmtpSendResult {
|
|
119
|
+
return {
|
|
120
|
+
messageId: info.messageId ?? "",
|
|
121
|
+
accepted: (info.accepted ?? []).map(addressOf),
|
|
122
|
+
rejected: (info.rejected ?? []).map(addressOf),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Send one {@link SmtpMessage} through an injected {@link SmtpTransport} and
|
|
128
|
+
* return a normalized {@link SmtpSendResult}. This is the pure, testable core:
|
|
129
|
+
* pass a real `nodemailer` transport in the host's `"use node"` action, or a fake
|
|
130
|
+
* one in a unit test. Throws when the transport throws (the host catches it and
|
|
131
|
+
* calls `markFailed`).
|
|
132
|
+
*
|
|
133
|
+
* @param transport - The injected transport (real nodemailer or a fake).
|
|
134
|
+
* @param message - The message to send.
|
|
135
|
+
* @param config - The resolved config, for the `from` default.
|
|
136
|
+
* @returns The normalized send result — store `messageId` as the queue `providerId`.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* // host "use node" action:
|
|
141
|
+
* const transport = createSmtpTransport(config); // real nodemailer
|
|
142
|
+
* const { messageId } = await sendViaSmtp(transport, { to, html }, config);
|
|
143
|
+
* await email.markSent(ctx, id, { providerId: messageId });
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
export async function sendViaSmtp(
|
|
147
|
+
transport: SmtpTransport,
|
|
148
|
+
message: SmtpMessage,
|
|
149
|
+
config: Pick<SmtpConfig, "from"> = {},
|
|
150
|
+
): Promise<SmtpSendResult> {
|
|
151
|
+
const options = toMailOptions(message, config);
|
|
152
|
+
const info = await transport.sendMail(options);
|
|
153
|
+
return toSendResult(info);
|
|
154
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The thin real-`nodemailer` wrapper — the ONLY piece that needs Node (raw SMTP
|
|
3
|
+
* sockets). It builds a real transport from a host {@link SmtpConfig} and binds it
|
|
4
|
+
* to {@link sendViaSmtp}. The host imports this from its OWN `"use node"` action;
|
|
5
|
+
* a Convex component runs in V8 and cannot host a Node action, so this never runs
|
|
6
|
+
* inside the sandboxed component.
|
|
7
|
+
*
|
|
8
|
+
* `nodemailer` is an **optional peer dependency** — the queue core installs and
|
|
9
|
+
* runs with zero third-party runtime deps. This module is import-tested only when
|
|
10
|
+
* the host opts into SMTP. It is **excluded from `coverage.include`**: it is a
|
|
11
|
+
* trivial pass-through to the real library, consumer-E2E verified (exactly as the
|
|
12
|
+
* `./react` live-backend integration is the consuming app's E2E). The pure
|
|
13
|
+
* {@link sendViaSmtp} + config validation it delegates to ARE covered at 100%.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import nodemailer from "nodemailer";
|
|
17
|
+
import { sendViaSmtp, validateSmtpConfig } from "./send.js";
|
|
18
|
+
import type { SmtpConfig, SmtpSender, SmtpTransport } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a real `nodemailer` SMTP transport from a host {@link SmtpConfig}. The
|
|
22
|
+
* config is validated first (throws on an invalid one). Generic over any SMTP
|
|
23
|
+
* server — Stalwart, Postfix, anything — the host supplies the connection.
|
|
24
|
+
*
|
|
25
|
+
* @param config - The host SMTP connection config.
|
|
26
|
+
* @returns A {@link SmtpTransport} backed by `nodemailer.createTransport`.
|
|
27
|
+
*/
|
|
28
|
+
export function createSmtpTransport(config: SmtpConfig): SmtpTransport {
|
|
29
|
+
const resolved = validateSmtpConfig(config);
|
|
30
|
+
return nodemailer.createTransport({
|
|
31
|
+
host: resolved.host,
|
|
32
|
+
port: resolved.port,
|
|
33
|
+
secure: resolved.secure,
|
|
34
|
+
auth: resolved.auth,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a bound {@link SmtpSender} over a real `nodemailer` transport: a function
|
|
40
|
+
* that sends one message and returns the normalized result. This is the one call
|
|
41
|
+
* a host wires into its `"use node"` flush action.
|
|
42
|
+
*
|
|
43
|
+
* @param config - The host SMTP connection config.
|
|
44
|
+
* @returns A sender that dispatches one {@link SmtpMessage} via the configured server.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* "use node";
|
|
49
|
+
* import { createSmtpSender } from "@vllnt/convex-email/smtp";
|
|
50
|
+
* const send = createSmtpSender({ host: process.env.SMTP_HOST!, port: 465, secure: true,
|
|
51
|
+
* auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! } });
|
|
52
|
+
* const { messageId } = await send({ to, from, subject, html });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function createSmtpSender(config: SmtpConfig): SmtpSender {
|
|
56
|
+
const transport = createSmtpTransport(config);
|
|
57
|
+
return (message) => sendViaSmtp(transport, message, config);
|
|
58
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript surface for the optional generic-SMTP transport adapter.
|
|
3
|
+
*
|
|
4
|
+
* Import this from your own `"use node"` action to send queued messages over any
|
|
5
|
+
* SMTP server. The component itself never sends — it records the message and its
|
|
6
|
+
* status; the SMTP server is host config.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generic SMTP connection config — works with Stalwart, Postfix, or any SMTP
|
|
11
|
+
* server. Host-supplied; mirrors the subset of nodemailer's transport options
|
|
12
|
+
* this adapter drives.
|
|
13
|
+
*/
|
|
14
|
+
export interface SmtpConfig {
|
|
15
|
+
/** SMTP server hostname (e.g. `"smtp.example.com"`, a Stalwart host, a Postfix relay). */
|
|
16
|
+
host: string;
|
|
17
|
+
/** SMTP server port (commonly 465 for implicit TLS, 587 for STARTTLS, 25 for relay). */
|
|
18
|
+
port: number;
|
|
19
|
+
/**
|
|
20
|
+
* Use implicit TLS on connect (`true` ⇒ typically port 465). When `false`,
|
|
21
|
+
* nodemailer upgrades via STARTTLS if the server offers it. Defaults to `true`
|
|
22
|
+
* when the port is 465, else `false` — set it explicitly to be unambiguous.
|
|
23
|
+
*/
|
|
24
|
+
secure?: boolean;
|
|
25
|
+
/** SMTP AUTH credentials. Omit for an unauthenticated relay (e.g. a localhost MTA). */
|
|
26
|
+
auth?: {
|
|
27
|
+
/** SMTP AUTH username. */
|
|
28
|
+
user: string;
|
|
29
|
+
/** SMTP AUTH password (a secret — keep it server-side; never ships to a client). */
|
|
30
|
+
pass: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Default `From` address used when a {@link SmtpMessage} omits `from`. The host
|
|
34
|
+
* supplies the opaque address; the adapter never invents one.
|
|
35
|
+
*/
|
|
36
|
+
from?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A single outbound message handed to the SMTP transport. Mirrors the queue's
|
|
41
|
+
* stored fields (`to`/`from` plus the rendered body) without coupling to the
|
|
42
|
+
* component's storage shape — the host maps a {@link MessageView} payload onto it.
|
|
43
|
+
*/
|
|
44
|
+
export interface SmtpMessage {
|
|
45
|
+
/** The recipient address (one address or a comma-separated list). */
|
|
46
|
+
to: string;
|
|
47
|
+
/** The sender address; falls back to {@link SmtpConfig.from} when omitted. */
|
|
48
|
+
from?: string;
|
|
49
|
+
/** The message subject line. */
|
|
50
|
+
subject?: string;
|
|
51
|
+
/** The plain-text body. At least one of `text`/`html` should be set. */
|
|
52
|
+
text?: string;
|
|
53
|
+
/** The HTML body. At least one of `text`/`html` should be set. */
|
|
54
|
+
html?: string;
|
|
55
|
+
/** Optional `Reply-To` address. */
|
|
56
|
+
replyTo?: string;
|
|
57
|
+
/** Optional extra SMTP headers (host-supplied, opaque to the adapter). */
|
|
58
|
+
headers?: Record<string, string>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The injected transport seam — the minimal surface {@link sendViaSmtp} drives.
|
|
63
|
+
* The real implementation is a `nodemailer` transporter; a fake one satisfies the
|
|
64
|
+
* same shape in tests, so the pure send logic is 100%-coverable with no network.
|
|
65
|
+
*/
|
|
66
|
+
export interface SmtpTransport {
|
|
67
|
+
/**
|
|
68
|
+
* Dispatch one message. Returns the transport's own send result; `sendViaSmtp`
|
|
69
|
+
* normalizes it to a {@link SmtpSendResult}. Throws on a send failure (the host
|
|
70
|
+
* catches it and calls `markFailed`).
|
|
71
|
+
*/
|
|
72
|
+
sendMail(options: SmtpMailOptions): Promise<SmtpSendInfo>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The mail options passed to {@link SmtpTransport.sendMail} — the nodemailer
|
|
77
|
+
* `sendMail` argument shape, narrowed to the fields this adapter sets. Declared
|
|
78
|
+
* locally so the public surface stays dependency-free (no `@types/nodemailer` in
|
|
79
|
+
* the published types).
|
|
80
|
+
*/
|
|
81
|
+
export interface SmtpMailOptions {
|
|
82
|
+
to: string;
|
|
83
|
+
from: string;
|
|
84
|
+
subject?: string;
|
|
85
|
+
text?: string;
|
|
86
|
+
html?: string;
|
|
87
|
+
replyTo?: string;
|
|
88
|
+
headers?: Record<string, string>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The raw result a transport's `sendMail` resolves with — the nodemailer
|
|
93
|
+
* `SentMessageInfo` subset this adapter reads. `accepted`/`rejected` are address
|
|
94
|
+
* lists; `messageId` is the SMTP message handle recorded as the queue's `providerId`.
|
|
95
|
+
*/
|
|
96
|
+
export interface SmtpSendInfo {
|
|
97
|
+
/** The SMTP message id assigned by the server (recorded as `providerId`). */
|
|
98
|
+
messageId?: string;
|
|
99
|
+
/** Addresses the server accepted. */
|
|
100
|
+
accepted?: ReadonlyArray<string | { address: string }>;
|
|
101
|
+
/** Addresses the server rejected. */
|
|
102
|
+
rejected?: ReadonlyArray<string | { address: string }>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The normalized result of {@link sendViaSmtp}. `messageId` is the transport's
|
|
107
|
+
* own handle (store it as the queue's `providerId` on `markSent`); `accepted` /
|
|
108
|
+
* `rejected` are flattened address lists.
|
|
109
|
+
*/
|
|
110
|
+
export interface SmtpSendResult {
|
|
111
|
+
/** The SMTP message handle, or `""` when the transport returned none. */
|
|
112
|
+
messageId: string;
|
|
113
|
+
/** Addresses the server accepted. */
|
|
114
|
+
accepted: string[];
|
|
115
|
+
/** Addresses the server rejected (non-empty even on a resolved send is a partial failure). */
|
|
116
|
+
rejected: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A bound sender: a function that sends one {@link SmtpMessage} through a
|
|
121
|
+
* preconfigured transport. {@link createSmtpSender} returns one over a real
|
|
122
|
+
* `nodemailer` transport; the host calls it inside its own `"use node"` action.
|
|
123
|
+
*/
|
|
124
|
+
export type SmtpSender = (message: SmtpMessage) => Promise<SmtpSendResult>;
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TestConvex } from "convex-test";
|
|
2
|
+
import schema from "./component/schema";
|
|
3
|
+
|
|
4
|
+
const modules = import.meta.glob("./component/**/*.ts");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register this component with a `convex-test` instance so consuming apps can
|
|
8
|
+
* test integration: `import { register } from "@vllnt/convex-email/test"`.
|
|
9
|
+
*/
|
|
10
|
+
export function register(t: TestConvex<typeof schema>, name = "email"): void {
|
|
11
|
+
t.registerComponent(name, schema, modules);
|
|
12
|
+
}
|