@mandujs/core 0.20.10 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -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
+ }