@mandujs/core 0.21.0 → 0.22.1
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/package.json +101 -69
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +310 -18
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1611 -59
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +11 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +518 -20
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +58 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/email
|
|
3
|
+
*
|
|
4
|
+
* Minimal transactional-email primitive: an `EmailSender` interface and two
|
|
5
|
+
* concrete adapters (`memory`, `resend`). Authentication flows (Phase 5.3 —
|
|
6
|
+
* email verification, password reset) consume this interface; they do not
|
|
7
|
+
* care which provider is wired up.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints:
|
|
10
|
+
* - **No external deps.** The resend adapter speaks HTTP via `fetch`.
|
|
11
|
+
* - **Send-only.** No MIME parsing inbound, no attachments (deferred to v2),
|
|
12
|
+
* no templating — callers build their own HTML.
|
|
13
|
+
* - **No queue / retry.** The caller (or a job runner) owns retries. This
|
|
14
|
+
* module is a transport primitive, not a mail pipeline.
|
|
15
|
+
* - **SMTP is stubbed.** See {@link ./smtp.ts} for the planned design.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { createResendSender } from "@mandujs/core/email";
|
|
20
|
+
*
|
|
21
|
+
* const mail = createResendSender({ apiKey: process.env.RESEND_API_KEY! });
|
|
22
|
+
* await mail.send({
|
|
23
|
+
* from: "Mandu <no-reply@mandu.dev>",
|
|
24
|
+
* to: "user@example.com",
|
|
25
|
+
* subject: "Verify your email",
|
|
26
|
+
* html: "<p>Click <a href=\"...\">here</a> to verify.</p>",
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @example Tests — swap in the memory adapter:
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { createMemoryEmailSender } from "@mandujs/core/email";
|
|
33
|
+
*
|
|
34
|
+
* const mail = createMemoryEmailSender();
|
|
35
|
+
* await handler({ mail }); // your code under test
|
|
36
|
+
* await mail.waitFor(1); // race-free assertion
|
|
37
|
+
* expect(mail.sent[0].subject).toBe("Verify your email");
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @module email
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { newId } from "../id/index.js";
|
|
44
|
+
|
|
45
|
+
// ─── Public types ───────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** A single outbound message. At least one of `html` / `text` is required. */
|
|
48
|
+
export interface EmailMessage {
|
|
49
|
+
/**
|
|
50
|
+
* Sender address. Accepted forms:
|
|
51
|
+
* - `"sender@domain.com"`
|
|
52
|
+
* - `"Display Name <sender@domain.com>"`
|
|
53
|
+
*/
|
|
54
|
+
from: string;
|
|
55
|
+
/** Single address or an array of addresses (≥ 1). */
|
|
56
|
+
to: string | string[];
|
|
57
|
+
/** Non-empty subject line. */
|
|
58
|
+
subject: string;
|
|
59
|
+
/** HTML body. Required if `text` is absent. */
|
|
60
|
+
html?: string;
|
|
61
|
+
/** Plain-text body. Required if `html` is absent. */
|
|
62
|
+
text?: string;
|
|
63
|
+
/** CC recipients (optional). */
|
|
64
|
+
cc?: string | string[];
|
|
65
|
+
/** BCC recipients (optional). */
|
|
66
|
+
bcc?: string | string[];
|
|
67
|
+
/** Reply-To address (optional). Mapped to `reply_to` on provider payloads. */
|
|
68
|
+
replyTo?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Arbitrary provider-specific headers. Keys are lowercased before being
|
|
71
|
+
* forwarded to the provider. Non-ASCII values may be rejected by the
|
|
72
|
+
* provider — we do not validate here.
|
|
73
|
+
*/
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Result of a successful send. */
|
|
78
|
+
export interface EmailSendResult {
|
|
79
|
+
/** Provider's message id. For the memory adapter, a synthetic UUIDv7. */
|
|
80
|
+
id: string;
|
|
81
|
+
/** Unix ms when the send call settled. */
|
|
82
|
+
sentAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Abstract transport. */
|
|
86
|
+
export interface EmailSender {
|
|
87
|
+
/**
|
|
88
|
+
* Sends a single message. Throws:
|
|
89
|
+
* - `TypeError` on validation failure (malformed message shape).
|
|
90
|
+
* - `Error` with status/context on transport failure (non-2xx, network).
|
|
91
|
+
*/
|
|
92
|
+
send(message: EmailMessage): Promise<EmailSendResult>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** In-process adapter for tests + dev. Single-process only — NOT distributed. */
|
|
96
|
+
export interface MemoryEmailSender extends EmailSender {
|
|
97
|
+
/** All messages sent since construction. Read-only, order preserved. */
|
|
98
|
+
readonly sent: ReadonlyArray<EmailMessage & { id: string; sentAt: number }>;
|
|
99
|
+
/** Empty the spool. Useful between test cases. */
|
|
100
|
+
clear(): void;
|
|
101
|
+
/**
|
|
102
|
+
* Resolves once `sent.length >= n`. Rejects after `timeoutMs`. Useful for
|
|
103
|
+
* tests that race with a request handler.
|
|
104
|
+
*
|
|
105
|
+
* @param n Target count. Default 1.
|
|
106
|
+
* @param timeoutMs Abort threshold in ms. Default 1000.
|
|
107
|
+
*/
|
|
108
|
+
waitFor(n?: number, timeoutMs?: number): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Accepts either a bare address (`"a@b.com"`) or display-name form
|
|
115
|
+
* (`"Name <a@b.com>"`). We deliberately do NOT implement RFC 5322 — that
|
|
116
|
+
* regex is famously 6kB and still imperfect. The provider will reject
|
|
117
|
+
* addresses it doesn't like.
|
|
118
|
+
*/
|
|
119
|
+
const BARE_EMAIL_RE = /^[^\s@]+@[^\s@]+$/;
|
|
120
|
+
const DISPLAY_NAME_RE = /^.+\s<[^\s@]+@[^\s@]+>$/;
|
|
121
|
+
|
|
122
|
+
function isValidFrom(from: string): boolean {
|
|
123
|
+
if (typeof from !== "string" || from.length === 0) return false;
|
|
124
|
+
return BARE_EMAIL_RE.test(from) || DISPLAY_NAME_RE.test(from);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeRecipients(
|
|
128
|
+
value: string | string[] | undefined,
|
|
129
|
+
): string[] | undefined {
|
|
130
|
+
if (value === undefined) return undefined;
|
|
131
|
+
if (typeof value === "string") return value.length > 0 ? [value] : [];
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validates `message` shape. Throws `TypeError` on the first problem found.
|
|
137
|
+
*
|
|
138
|
+
* Exported for tests and for callers who want to pre-check a message before
|
|
139
|
+
* enqueueing it.
|
|
140
|
+
*
|
|
141
|
+
* @internal
|
|
142
|
+
*/
|
|
143
|
+
export function _validateMessage(message: EmailMessage): void {
|
|
144
|
+
if (!message || typeof message !== "object") {
|
|
145
|
+
throw new TypeError("[@mandujs/core/email] send: message must be an object.");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!isValidFrom(message.from)) {
|
|
149
|
+
throw new TypeError(
|
|
150
|
+
`[@mandujs/core/email] send: 'from' must be an email address or "Name <addr>" form (got ${JSON.stringify(
|
|
151
|
+
message.from,
|
|
152
|
+
)}).`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const to = normalizeRecipients(message.to);
|
|
157
|
+
if (!to || to.length === 0) {
|
|
158
|
+
throw new TypeError(
|
|
159
|
+
"[@mandujs/core/email] send: 'to' must be a non-empty string or non-empty array.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const addr of to) {
|
|
163
|
+
if (typeof addr !== "string" || addr.length === 0) {
|
|
164
|
+
throw new TypeError(
|
|
165
|
+
"[@mandujs/core/email] send: 'to' entries must be non-empty strings.",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (typeof message.subject !== "string" || message.subject.length === 0) {
|
|
171
|
+
throw new TypeError(
|
|
172
|
+
"[@mandujs/core/email] send: 'subject' must be a non-empty string.",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hasHtml = typeof message.html === "string" && message.html.length > 0;
|
|
177
|
+
const hasText = typeof message.text === "string" && message.text.length > 0;
|
|
178
|
+
if (!hasHtml && !hasText) {
|
|
179
|
+
throw new TypeError(
|
|
180
|
+
"[@mandujs/core/email] send: message must include at least one of 'html' or 'text'.",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Shared coercion used by provider adapters: turn a `EmailMessage` into a
|
|
187
|
+
* `{ to, cc, bcc }` bundle of string arrays for the provider payload. Omits
|
|
188
|
+
* undefined-valued fields entirely so the provider doesn't see `null`s.
|
|
189
|
+
*
|
|
190
|
+
* @internal
|
|
191
|
+
*/
|
|
192
|
+
export function _coerceRecipients(message: EmailMessage): {
|
|
193
|
+
to: string[];
|
|
194
|
+
cc?: string[];
|
|
195
|
+
bcc?: string[];
|
|
196
|
+
} {
|
|
197
|
+
const out: { to: string[]; cc?: string[]; bcc?: string[] } = {
|
|
198
|
+
to: normalizeRecipients(message.to) ?? [],
|
|
199
|
+
};
|
|
200
|
+
const cc = normalizeRecipients(message.cc);
|
|
201
|
+
if (cc && cc.length > 0) out.cc = cc;
|
|
202
|
+
const bcc = normalizeRecipients(message.bcc);
|
|
203
|
+
if (bcc && bcc.length > 0) out.bcc = bcc;
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Lowercases the keys of a headers map. Keeps duplicate-key semantics to
|
|
209
|
+
* whichever one the caller supplied last — same as the JS object itself.
|
|
210
|
+
*
|
|
211
|
+
* @internal
|
|
212
|
+
*/
|
|
213
|
+
export function _lowercaseHeaders(
|
|
214
|
+
headers: Record<string, string> | undefined,
|
|
215
|
+
): Record<string, string> | undefined {
|
|
216
|
+
if (!headers) return undefined;
|
|
217
|
+
const out: Record<string, string> = {};
|
|
218
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
219
|
+
out[k.toLowerCase()] = v;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Memory adapter ─────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Creates an in-process email sender. Messages never leave the current
|
|
228
|
+
* process — perfect for unit tests and dev environments.
|
|
229
|
+
*/
|
|
230
|
+
export function createMemoryEmailSender(): MemoryEmailSender {
|
|
231
|
+
const spool: Array<EmailMessage & { id: string; sentAt: number }> = [];
|
|
232
|
+
|
|
233
|
+
async function send(message: EmailMessage): Promise<EmailSendResult> {
|
|
234
|
+
_validateMessage(message);
|
|
235
|
+
const id = newId();
|
|
236
|
+
const sentAt = Date.now();
|
|
237
|
+
// Freeze a shallow snapshot so the spool entry doesn't mutate if the
|
|
238
|
+
// caller reuses the `message` object.
|
|
239
|
+
spool.push({
|
|
240
|
+
...message,
|
|
241
|
+
id,
|
|
242
|
+
sentAt,
|
|
243
|
+
});
|
|
244
|
+
return { id, sentAt };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function clear(): void {
|
|
248
|
+
spool.length = 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function waitFor(n: number = 1, timeoutMs: number = 1000): Promise<void> {
|
|
252
|
+
if (n <= 0 || spool.length >= n) return;
|
|
253
|
+
const deadline = Date.now() + timeoutMs;
|
|
254
|
+
// Poll on a 10ms cadence — simpler than wiring a notifier and plenty
|
|
255
|
+
// responsive for test assertions.
|
|
256
|
+
while (spool.length < n) {
|
|
257
|
+
if (Date.now() >= deadline) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`[@mandujs/core/email] waitFor timed out after ${timeoutMs}ms (got ${spool.length}/${n} messages).`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
send,
|
|
268
|
+
clear,
|
|
269
|
+
waitFor,
|
|
270
|
+
// Expose the array itself as a readonly view. `ReadonlyArray` is purely
|
|
271
|
+
// a TS-level guarantee; consumers that mutate via `as unknown as` are
|
|
272
|
+
// reaching past the type system and get what they deserve.
|
|
273
|
+
get sent() {
|
|
274
|
+
return spool as ReadonlyArray<EmailMessage & { id: string; sentAt: number }>;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Re-exports ─────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
export { createResendSender, type ResendOptions } from "./resend.js";
|
|
282
|
+
export { createSmtpSender, type SmtpOptions } from "./smtp.js";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/email — Resend adapter
|
|
3
|
+
*
|
|
4
|
+
* Thin HTTP client for the Resend Send-Email API. Zero deps: uses `fetch`.
|
|
5
|
+
*
|
|
6
|
+
* Contract (https://resend.com/docs/api-reference/emails/send-email):
|
|
7
|
+
* POST https://api.resend.com/emails
|
|
8
|
+
* Headers:
|
|
9
|
+
* Authorization: Bearer <apiKey>
|
|
10
|
+
* Content-Type: application/json
|
|
11
|
+
* Body: {
|
|
12
|
+
* from, to (string | string[]), subject,
|
|
13
|
+
* html?, text?, cc?, bcc?, reply_to?, headers?
|
|
14
|
+
* }
|
|
15
|
+
* Response 200: { id: string, ... }
|
|
16
|
+
* Error 4xx/5xx: { name, message, statusCode }
|
|
17
|
+
*
|
|
18
|
+
* @module email/resend
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
type EmailMessage,
|
|
23
|
+
type EmailSender,
|
|
24
|
+
type EmailSendResult,
|
|
25
|
+
_coerceRecipients,
|
|
26
|
+
_lowercaseHeaders,
|
|
27
|
+
_validateMessage,
|
|
28
|
+
} from "./index.js";
|
|
29
|
+
|
|
30
|
+
/** Config for the Resend adapter. */
|
|
31
|
+
export interface ResendOptions {
|
|
32
|
+
/** Resend API key. Required. Pull from env (e.g. `RESEND_API_KEY`). */
|
|
33
|
+
apiKey: string;
|
|
34
|
+
/**
|
|
35
|
+
* Override API base URL. Useful for tests or private Resend deployments.
|
|
36
|
+
* Default: `"https://api.resend.com"`. Trailing slash is stripped.
|
|
37
|
+
*/
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Fetch implementation. Default: `globalThis.fetch`. Injected in tests so
|
|
41
|
+
* we never hit the real network.
|
|
42
|
+
*/
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Body shape posted to Resend. Fields are omitted when undefined. */
|
|
47
|
+
interface ResendRequestBody {
|
|
48
|
+
from: string;
|
|
49
|
+
to: string[];
|
|
50
|
+
subject: string;
|
|
51
|
+
html?: string;
|
|
52
|
+
text?: string;
|
|
53
|
+
cc?: string[];
|
|
54
|
+
bcc?: string[];
|
|
55
|
+
reply_to?: string;
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Successful Resend response shape. We only care about `id`. */
|
|
60
|
+
interface ResendSuccessResponse {
|
|
61
|
+
id: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** How much of a failed-response body we include in the thrown error. */
|
|
65
|
+
const ERROR_BODY_EXCERPT_CHARS = 200;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the request body in exactly the shape Resend expects. Exported for
|
|
69
|
+
* tests so they can assert the payload shape without running `send()`.
|
|
70
|
+
*
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export function _buildResendBody(message: EmailMessage): ResendRequestBody {
|
|
74
|
+
const recipients = _coerceRecipients(message);
|
|
75
|
+
const body: ResendRequestBody = {
|
|
76
|
+
from: message.from,
|
|
77
|
+
to: recipients.to,
|
|
78
|
+
subject: message.subject,
|
|
79
|
+
};
|
|
80
|
+
if (message.html !== undefined) body.html = message.html;
|
|
81
|
+
if (message.text !== undefined) body.text = message.text;
|
|
82
|
+
if (recipients.cc) body.cc = recipients.cc;
|
|
83
|
+
if (recipients.bcc) body.bcc = recipients.bcc;
|
|
84
|
+
if (message.replyTo !== undefined) body.reply_to = message.replyTo;
|
|
85
|
+
const headers = _lowercaseHeaders(message.headers);
|
|
86
|
+
if (headers && Object.keys(headers).length > 0) body.headers = headers;
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates a Resend-backed `EmailSender`.
|
|
92
|
+
*
|
|
93
|
+
* @throws `TypeError` if `apiKey` is missing.
|
|
94
|
+
*/
|
|
95
|
+
export function createResendSender(options: ResendOptions): EmailSender {
|
|
96
|
+
if (!options || typeof options.apiKey !== "string" || options.apiKey.length === 0) {
|
|
97
|
+
throw new TypeError(
|
|
98
|
+
"[@mandujs/core/email/resend] createResendSender: 'apiKey' is required.",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const baseUrl = (options.baseUrl ?? "https://api.resend.com").replace(/\/+$/, "");
|
|
103
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
104
|
+
if (typeof fetchImpl !== "function") {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"[@mandujs/core/email/resend] globalThis.fetch is unavailable — provide `options.fetch` or run in an environment that supplies fetch.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const url = `${baseUrl}/emails`;
|
|
111
|
+
const authHeader = `Bearer ${options.apiKey}`;
|
|
112
|
+
|
|
113
|
+
async function send(message: EmailMessage): Promise<EmailSendResult> {
|
|
114
|
+
_validateMessage(message);
|
|
115
|
+
const body = _buildResendBody(message);
|
|
116
|
+
|
|
117
|
+
let response: Response;
|
|
118
|
+
try {
|
|
119
|
+
response = await fetchImpl(url, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: authHeader,
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify(body),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Network-level failure (DNS, TCP reset, abort, offline). Re-throw
|
|
129
|
+
// with clear context so ops dashboards can distinguish "provider said
|
|
130
|
+
// no" from "we never reached the provider".
|
|
131
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[@mandujs/core/email/resend] email send failed: ${cause}`,
|
|
134
|
+
{ cause: err instanceof Error ? err : undefined },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
// Read the body best-effort so the error is actionable, but don't let
|
|
140
|
+
// a malformed body mask the HTTP status.
|
|
141
|
+
let excerpt = "";
|
|
142
|
+
try {
|
|
143
|
+
const text = await response.text();
|
|
144
|
+
excerpt = text.slice(0, ERROR_BODY_EXCERPT_CHARS);
|
|
145
|
+
} catch {
|
|
146
|
+
excerpt = "<unreadable body>";
|
|
147
|
+
}
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[@mandujs/core/email/resend] email send failed: status=${response.status} body=${excerpt}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = (await response.json()) as ResendSuccessResponse;
|
|
154
|
+
if (!parsed || typeof parsed.id !== "string") {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"[@mandujs/core/email/resend] email send failed: provider response missing 'id'.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return { id: parsed.id, sentAt: Date.now() };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { send };
|
|
163
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/email — SMTP adapter (stub)
|
|
3
|
+
*
|
|
4
|
+
* Intentionally not implemented in Phase 5.2. This file reserves the import
|
|
5
|
+
* surface so that Phase 5.3 and downstream callers can write
|
|
6
|
+
*
|
|
7
|
+
* import { createSmtpSender } from "@mandujs/core/email";
|
|
8
|
+
*
|
|
9
|
+
* and decide at runtime whether to fall back to `createResendSender` or
|
|
10
|
+
* `createMemoryEmailSender`. Calling the factory throws a clear, actionable
|
|
11
|
+
* error — never a silent no-op.
|
|
12
|
+
*
|
|
13
|
+
* ## Design note (v0.2 plan)
|
|
14
|
+
*
|
|
15
|
+
* The planned implementation has two viable paths:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Bun-native** — open a TCP socket with `Bun.connect()`, upgrade to
|
|
18
|
+
* TLS via `Bun.connect({ tls: true })` (or `STARTTLS` on port 587),
|
|
19
|
+
* then speak the SMTP state machine by hand: `EHLO` → `AUTH LOGIN`
|
|
20
|
+
* (base64 user / pass) → `MAIL FROM` → `RCPT TO` (×n) → `DATA` → CRLF
|
|
21
|
+
* dot-stuffed body → `.` → `QUIT`. This keeps the zero-deps promise
|
|
22
|
+
* and matches the rest of the package's Bun-native posture. Total
|
|
23
|
+
* ≈ 300 LOC; the RFC 5321 happy path is small, it's the edge cases
|
|
24
|
+
* (pipelining, CHUNKING, 8BITMIME negotiation, XOAUTH2, line folding
|
|
25
|
+
* at 998 chars) that balloon the complexity.
|
|
26
|
+
*
|
|
27
|
+
* 2. **nodemailer as a peer dep** — battle-tested, handles every edge
|
|
28
|
+
* case. Trade-off: drops "zero deps" for this adapter, and nodemailer
|
|
29
|
+
* is CommonJS-heavy. We'd wire it as an optional peer so apps only
|
|
30
|
+
* take the hit if they opt in.
|
|
31
|
+
*
|
|
32
|
+
* Current leaning: start with path (1) for a constrained feature set (TLS
|
|
33
|
+
* on 465, AUTH PLAIN/LOGIN, single-recipient per RCPT, no attachments),
|
|
34
|
+
* escape-hatch to (2) if we hit providers that need the full RFC. Either
|
|
35
|
+
* path must preserve the `EmailSender` contract with no behavioural drift
|
|
36
|
+
* vs the Resend adapter (same validation, same error shape).
|
|
37
|
+
*
|
|
38
|
+
* @module email/smtp
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { EmailSender } from "./index.js";
|
|
42
|
+
|
|
43
|
+
/** Config for the (not-yet-implemented) SMTP adapter. */
|
|
44
|
+
export interface SmtpOptions {
|
|
45
|
+
host: string;
|
|
46
|
+
port?: number;
|
|
47
|
+
secure?: boolean;
|
|
48
|
+
auth?: { user: string; pass: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Throws at call time. The adapter is planned for v0.2.
|
|
53
|
+
*
|
|
54
|
+
* @throws Always. Use {@link createResendSender} or
|
|
55
|
+
* {@link createMemoryEmailSender} instead.
|
|
56
|
+
*/
|
|
57
|
+
export function createSmtpSender(_options: SmtpOptions): EmailSender {
|
|
58
|
+
// TODO(phase-5.2+): implement via Bun.connect() + TLS upgrade, OR wire
|
|
59
|
+
// nodemailer as an optional peer dep. See module-level JSDoc for the
|
|
60
|
+
// design trade-off.
|
|
61
|
+
throw new Error(
|
|
62
|
+
"[@mandujs/core/email/smtp] Phase 5.2: SMTP adapter is planned but not yet implemented. Use createResendSender or createMemoryEmailSender.",
|
|
63
|
+
);
|
|
64
|
+
}
|