@lunora/mail 0.0.0 → 1.0.0-alpha.2

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.
@@ -0,0 +1,62 @@
1
+ const MAX_EMAIL_LENGTH = 320;
2
+ const MAX_NAME_LENGTH = 256;
3
+ const ADDRESS_PATTERN = /^([^<]*)<([^>]*)>\s*$/;
4
+ const assertSafeAddressField = (field, value) => {
5
+ if (value.includes("\r") || value.includes("\n") || value.includes(",")) {
6
+ throw new Error(`@lunora/mail: address ${field} must not contain CR, LF, or comma`);
7
+ }
8
+ };
9
+ const assertSafeHeaderValue = (label, value) => {
10
+ if (value.includes("\r") || value.includes("\n")) {
11
+ throw new Error(`@lunora/mail: ${label} must not contain CR or LF`);
12
+ }
13
+ };
14
+ const toBracketedAddress = (name, email) => {
15
+ if (name.length > MAX_NAME_LENGTH) {
16
+ throw new Error(`@lunora/mail: address name must be <= ${String(MAX_NAME_LENGTH)} characters`);
17
+ }
18
+ if (email.length > MAX_EMAIL_LENGTH) {
19
+ throw new Error(`@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
20
+ }
21
+ if (name) {
22
+ assertSafeAddressField("name", name);
23
+ }
24
+ assertSafeAddressField("email", email);
25
+ return name ? { email, name } : { email };
26
+ };
27
+ const toBareAddress = (input) => {
28
+ const email = input.trim();
29
+ if (email.length > MAX_EMAIL_LENGTH) {
30
+ throw new Error(`@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
31
+ }
32
+ assertSafeAddressField("email", email);
33
+ return { email };
34
+ };
35
+ const toAddress = (input) => {
36
+ const match = ADDRESS_PATTERN.exec(input);
37
+ const email = (match?.[2] ?? "").trim();
38
+ if (match && email) {
39
+ return toBracketedAddress((match[1] ?? "").trim(), email);
40
+ }
41
+ return toBareAddress(input);
42
+ };
43
+ const toAddressList = (input) => {
44
+ if (input === void 0) {
45
+ return void 0;
46
+ }
47
+ const list = Array.isArray(input) ? input : [input];
48
+ return list.map((entry) => toAddress(entry));
49
+ };
50
+ const assertSafeAddresses = (payload) => {
51
+ toAddressList(payload.to);
52
+ toAddressList(payload.cc);
53
+ toAddressList(payload.bcc);
54
+ if (payload.from !== void 0) {
55
+ toAddress(payload.from);
56
+ }
57
+ if (payload.replyTo !== void 0) {
58
+ toAddress(payload.replyTo);
59
+ }
60
+ };
61
+
62
+ export { assertSafeHeaderValue as a, assertSafeAddresses as b, toAddress as c, toAddressList as t };
@@ -0,0 +1,117 @@
1
+ import { ReactElement } from 'react';
2
+ /**
3
+ * Minimal projection of a Cloudflare Queue binding — accepts a JSON payload
4
+ * via `.send()`. Declared structurally so callers can pass either the real
5
+ * `Queue` binding or a unit-test double.
6
+ */
7
+ interface QueueLike {
8
+ send: (payload: unknown, options?: Record<string, unknown>) => Promise<void>;
9
+ }
10
+ /**
11
+ * Minimal projection of a transport adapter. Returning `{ id }` follows
12
+ * Resend's response shape; the real `@visulima/email` `MailManager` flattens
13
+ * provider responses to the same field for us.
14
+ */
15
+ interface MailTransport {
16
+ send: (payload: SendPayload) => Promise<{
17
+ id: string;
18
+ }>;
19
+ }
20
+ interface SendPayload {
21
+ bcc?: string[];
22
+ cc?: string[];
23
+ from?: string;
24
+ headers?: Record<string, string>;
25
+ html?: string;
26
+ replyTo?: string;
27
+ subject: string;
28
+ text?: string;
29
+ to: string | string[];
30
+ }
31
+ interface SendOptions {
32
+ bcc?: string[];
33
+ cc?: string[];
34
+ from?: string;
35
+ headers?: Record<string, string>;
36
+ html?: string;
37
+ react?: ReactElement;
38
+ replyTo?: string;
39
+ subject: string;
40
+ text?: string;
41
+ to: string | string[];
42
+ }
43
+ interface LunoraMailOptions {
44
+ /** API key for the Resend transport (bring-your-own-provider). Ignored when `transport` or `cloudflareSend` is set. */
45
+ apiKey?: string;
46
+ /**
47
+ * RFC 822 send callback bound to the Worker's `send_email` binding. When set
48
+ * (and no explicit `transport` is supplied) the default transport is
49
+ * Cloudflare Email Workers — Lunora's default provider. Ignored when
50
+ * `transport` is set.
51
+ */
52
+ cloudflareSend?: (from: string, to: string, raw: string) => Promise<void>;
53
+ /** Default sender (`Name &lt;addr@host>` or bare email). */
54
+ from: string;
55
+ /** Default queue binding for `mailer.queue()`. */
56
+ queue?: QueueLike;
57
+ /** Override the underlying transport. Useful for tests, the dev capture transport, + multi-provider setups. */
58
+ transport?: MailTransport;
59
+ }
60
+ interface Mailer {
61
+ queue: (options: SendOptions) => Promise<{
62
+ queued: true;
63
+ }>;
64
+ send: (options: SendOptions) => Promise<{
65
+ id: string;
66
+ }>;
67
+ }
68
+ /**
69
+ * One captured outbound message as persisted by the dev mail catcher. Extends
70
+ * the rendered, validated {@link SendPayload} with an `id` and a capture
71
+ * timestamp assigned by the sink (the root-shard mailbox), so the studio inbox
72
+ * can list and open it.
73
+ *
74
+ * **Canonical captured-mail wire type — single source of truth.** Every other
75
+ * representation of a captured message mirrors this shape; consumers import it
76
+ * directly wherever the package dependency direction allows.
77
+ *
78
+ * `@lunora/studio`'s `CapturedMail` re-exports this (type-only dep on
79
+ * `@lunora/mail`). `@lunora/do`'s `CapturedMailRow` / `RecordMailInput` are
80
+ * documented mirrors — the DO runtime stays free of any `@lunora/mail` *runtime*
81
+ * dep — guarded by a compile-time structural assertion against this type, so a
82
+ * field added here that isn't mirrored fails the `@lunora/do` build.
83
+ *
84
+ * Add or change a captured-mail field here first; the guards will point at the
85
+ * mirrors that need the matching change.
86
+ */
87
+ interface CapturedMail extends SendPayload {
88
+ /** Epoch-ms the message was captured. */
89
+ capturedAt: number;
90
+ /** Stable id assigned to the captured message. */
91
+ id: string;
92
+ }
93
+ /**
94
+ * Minimal projection of the persistence target the capture transport writes to
95
+ * (the studio's root-shard mailbox). Declared structurally — like
96
+ * {@link import("./types").QueueLike} — so `@lunora/mail` stays free of any
97
+ * Durable Object / runtime dependency. The registry scaffold supplies the
98
+ * concrete sink that POSTs to the root shard's `__lunora_admin__:recordMail` RPC.
99
+ */
100
+ interface MailboxSink {
101
+ record: (mail: SendPayload) => Promise<{
102
+ id: string;
103
+ }>;
104
+ }
105
+ /**
106
+ * Build a capture {@link MailTransport}: instead of delivering, it persists the
107
+ * fully rendered + validated payload to `sink` and returns the assigned id.
108
+ *
109
+ * Wired in dev by the mail registry scaffold so `lunora dev` shows every send in
110
+ * the studio's Mail inbox — including `@lunora/auth`'s verification and
111
+ * forgot-password mail — with no provider credentials and nothing leaving the
112
+ * machine. Address/header validation already ran in `createMailer.buildPayload`
113
+ * before the payload reaches here, so the captured message is the same one a
114
+ * real transport would have sent.
115
+ */
116
+ declare const createCaptureTransport: (sink: MailboxSink) => MailTransport;
117
+ export { CapturedMail as C, LunoraMailOptions as L, MailTransport as M, QueueLike as Q, SendOptions as S, Mailer as a, MailboxSink as b, SendPayload as c, createCaptureTransport as d };
@@ -0,0 +1,117 @@
1
+ import { ReactElement } from 'react';
2
+ /**
3
+ * Minimal projection of a Cloudflare Queue binding — accepts a JSON payload
4
+ * via `.send()`. Declared structurally so callers can pass either the real
5
+ * `Queue` binding or a unit-test double.
6
+ */
7
+ interface QueueLike {
8
+ send: (payload: unknown, options?: Record<string, unknown>) => Promise<void>;
9
+ }
10
+ /**
11
+ * Minimal projection of a transport adapter. Returning `{ id }` follows
12
+ * Resend's response shape; the real `@visulima/email` `MailManager` flattens
13
+ * provider responses to the same field for us.
14
+ */
15
+ interface MailTransport {
16
+ send: (payload: SendPayload) => Promise<{
17
+ id: string;
18
+ }>;
19
+ }
20
+ interface SendPayload {
21
+ bcc?: string[];
22
+ cc?: string[];
23
+ from?: string;
24
+ headers?: Record<string, string>;
25
+ html?: string;
26
+ replyTo?: string;
27
+ subject: string;
28
+ text?: string;
29
+ to: string | string[];
30
+ }
31
+ interface SendOptions {
32
+ bcc?: string[];
33
+ cc?: string[];
34
+ from?: string;
35
+ headers?: Record<string, string>;
36
+ html?: string;
37
+ react?: ReactElement;
38
+ replyTo?: string;
39
+ subject: string;
40
+ text?: string;
41
+ to: string | string[];
42
+ }
43
+ interface LunoraMailOptions {
44
+ /** API key for the Resend transport (bring-your-own-provider). Ignored when `transport` or `cloudflareSend` is set. */
45
+ apiKey?: string;
46
+ /**
47
+ * RFC 822 send callback bound to the Worker's `send_email` binding. When set
48
+ * (and no explicit `transport` is supplied) the default transport is
49
+ * Cloudflare Email Workers — Lunora's default provider. Ignored when
50
+ * `transport` is set.
51
+ */
52
+ cloudflareSend?: (from: string, to: string, raw: string) => Promise<void>;
53
+ /** Default sender (`Name &lt;addr@host>` or bare email). */
54
+ from: string;
55
+ /** Default queue binding for `mailer.queue()`. */
56
+ queue?: QueueLike;
57
+ /** Override the underlying transport. Useful for tests, the dev capture transport, + multi-provider setups. */
58
+ transport?: MailTransport;
59
+ }
60
+ interface Mailer {
61
+ queue: (options: SendOptions) => Promise<{
62
+ queued: true;
63
+ }>;
64
+ send: (options: SendOptions) => Promise<{
65
+ id: string;
66
+ }>;
67
+ }
68
+ /**
69
+ * One captured outbound message as persisted by the dev mail catcher. Extends
70
+ * the rendered, validated {@link SendPayload} with an `id` and a capture
71
+ * timestamp assigned by the sink (the root-shard mailbox), so the studio inbox
72
+ * can list and open it.
73
+ *
74
+ * **Canonical captured-mail wire type — single source of truth.** Every other
75
+ * representation of a captured message mirrors this shape; consumers import it
76
+ * directly wherever the package dependency direction allows.
77
+ *
78
+ * `@lunora/studio`'s `CapturedMail` re-exports this (type-only dep on
79
+ * `@lunora/mail`). `@lunora/do`'s `CapturedMailRow` / `RecordMailInput` are
80
+ * documented mirrors — the DO runtime stays free of any `@lunora/mail` *runtime*
81
+ * dep — guarded by a compile-time structural assertion against this type, so a
82
+ * field added here that isn't mirrored fails the `@lunora/do` build.
83
+ *
84
+ * Add or change a captured-mail field here first; the guards will point at the
85
+ * mirrors that need the matching change.
86
+ */
87
+ interface CapturedMail extends SendPayload {
88
+ /** Epoch-ms the message was captured. */
89
+ capturedAt: number;
90
+ /** Stable id assigned to the captured message. */
91
+ id: string;
92
+ }
93
+ /**
94
+ * Minimal projection of the persistence target the capture transport writes to
95
+ * (the studio's root-shard mailbox). Declared structurally — like
96
+ * {@link import("./types").QueueLike} — so `@lunora/mail` stays free of any
97
+ * Durable Object / runtime dependency. The registry scaffold supplies the
98
+ * concrete sink that POSTs to the root shard's `__lunora_admin__:recordMail` RPC.
99
+ */
100
+ interface MailboxSink {
101
+ record: (mail: SendPayload) => Promise<{
102
+ id: string;
103
+ }>;
104
+ }
105
+ /**
106
+ * Build a capture {@link MailTransport}: instead of delivering, it persists the
107
+ * fully rendered + validated payload to `sink` and returns the assigned id.
108
+ *
109
+ * Wired in dev by the mail registry scaffold so `lunora dev` shows every send in
110
+ * the studio's Mail inbox — including `@lunora/auth`'s verification and
111
+ * forgot-password mail — with no provider credentials and nothing leaving the
112
+ * machine. Address/header validation already ran in `createMailer.buildPayload`
113
+ * before the payload reaches here, so the captured message is the same one a
114
+ * real transport would have sent.
115
+ */
116
+ declare const createCaptureTransport: (sink: MailboxSink) => MailTransport;
117
+ export { CapturedMail as C, LunoraMailOptions as L, MailTransport as M, QueueLike as Q, SendOptions as S, Mailer as a, MailboxSink as b, SendPayload as c, createCaptureTransport as d };
@@ -0,0 +1,75 @@
1
+ const toQueuedPayload = (options) => {
2
+ return {
3
+ bcc: options.bcc,
4
+ cc: options.cc,
5
+ from: options.from,
6
+ headers: options.headers,
7
+ html: options.html,
8
+ replyTo: options.replyTo,
9
+ subject: options.subject,
10
+ text: options.text,
11
+ to: options.to
12
+ };
13
+ };
14
+ const consumeQueuedSend = async (mailer, payload) => {
15
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
16
+ throw new Error("@lunora/mail: queue message body must be an object");
17
+ }
18
+ const candidate = payload;
19
+ if (typeof candidate.subject !== "string") {
20
+ throw new TypeError("@lunora/mail: queue message must have a string `subject`");
21
+ }
22
+ const recipientIsString = typeof candidate.to === "string";
23
+ const recipientIsStringArray = Array.isArray(candidate.to) && candidate.to.every((value) => typeof value === "string");
24
+ if (!recipientIsString && !recipientIsStringArray) {
25
+ throw new Error("@lunora/mail: queue message `to` must be a string or string[]");
26
+ }
27
+ const assertOptionalString = (field, value) => {
28
+ if (value === void 0) {
29
+ return void 0;
30
+ }
31
+ if (typeof value !== "string") {
32
+ throw new TypeError(`@lunora/mail: queue message \`${field}\` must be a string`);
33
+ }
34
+ return value;
35
+ };
36
+ const assertOptionalStringList = (field, value) => {
37
+ if (value === void 0) {
38
+ return void 0;
39
+ }
40
+ if (typeof value === "string") {
41
+ return [value];
42
+ }
43
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
44
+ return value;
45
+ }
46
+ throw new TypeError(`@lunora/mail: queue message \`${field}\` must be a string or string[]`);
47
+ };
48
+ let headers;
49
+ if (candidate.headers !== void 0) {
50
+ if (!candidate.headers || typeof candidate.headers !== "object" || Array.isArray(candidate.headers)) {
51
+ throw new TypeError("@lunora/mail: queue message `headers` must be an object of string values");
52
+ }
53
+ const entries = Object.entries(candidate.headers);
54
+ for (const [key, value] of entries) {
55
+ if (typeof value !== "string") {
56
+ throw new TypeError(`@lunora/mail: queue message header "${key}" must be a string`);
57
+ }
58
+ }
59
+ headers = candidate.headers;
60
+ }
61
+ const options = {
62
+ bcc: assertOptionalStringList("bcc", candidate.bcc),
63
+ cc: assertOptionalStringList("cc", candidate.cc),
64
+ from: assertOptionalString("from", candidate.from),
65
+ headers,
66
+ html: assertOptionalString("html", candidate.html),
67
+ replyTo: assertOptionalString("replyTo", candidate.replyTo),
68
+ subject: candidate.subject,
69
+ text: assertOptionalString("text", candidate.text),
70
+ to: candidate.to
71
+ };
72
+ return mailer.send(options);
73
+ };
74
+
75
+ export { consumeQueuedSend, toQueuedPayload };
@@ -0,0 +1,63 @@
1
+ import { createCaptureTransport } from './createCaptureTransport-Crz_8822.mjs';
2
+ import createMailer from './createMailer-oEKPAd4J.mjs';
3
+ import { a as applyJurisdiction } from './shard-CJ-TvmfT.mjs';
4
+
5
+ const RECORD_MAIL_OP = "__lunora_admin__:recordMail";
6
+ const DEFAULT_ROOT_SHARD = "__root__";
7
+ const DEV_ENVIRONMENT_PATTERN = /^(?:dev(?:elopment)?|local(?:host)?|test)$/iu;
8
+ const ENVIRONMENT_VARS = ["CF_ENV", "ENVIRONMENT", "NODE_ENV", "WORKER_ENV"];
9
+ const requireStringEnv = (env, name) => {
10
+ const value = env[name];
11
+ if (typeof value !== "string" || value === "") {
12
+ throw new Error(`@lunora/mail: missing env var \`${name}\` — set it in .dev.vars (and \`wrangler secret put ${name}\` for secrets).`);
13
+ }
14
+ return value;
15
+ };
16
+ const shouldCaptureMail = (env) => {
17
+ const flag = env["LUNORA_MAIL_CAPTURE"];
18
+ if (typeof flag === "string") {
19
+ return flag === "1" || flag.toLowerCase() === "true";
20
+ }
21
+ return ENVIRONMENT_VARS.some((key) => {
22
+ const value = env[key];
23
+ return typeof value === "string" && DEV_ENVIRONMENT_PATTERN.test(value);
24
+ });
25
+ };
26
+ const createCaptureSink = (env, rootShard = DEFAULT_ROOT_SHARD, jurisdiction) => {
27
+ return {
28
+ record: async (mail) => {
29
+ const binding = env["SHARD"];
30
+ const adminToken = typeof env["LUNORA_ADMIN_TOKEN"] === "string" ? env["LUNORA_ADMIN_TOKEN"] : void 0;
31
+ if (binding === void 0 || adminToken === void 0) {
32
+ return { id: "uncaptured" };
33
+ }
34
+ const namespace = applyJurisdiction(binding, jurisdiction);
35
+ const stub = namespace.get(namespace.idFromName(rootShard));
36
+ const response = await stub.fetch("https://shard.internal/rpc", {
37
+ body: JSON.stringify({ args: mail, functionPath: RECORD_MAIL_OP }),
38
+ headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
39
+ method: "POST"
40
+ });
41
+ const body = await response.json();
42
+ return { id: body.result?.id ?? "captured" };
43
+ }
44
+ };
45
+ };
46
+ const createMailerFromEnv = (env, options = {}) => {
47
+ const from = requireStringEnv(env, "MAIL_FROM");
48
+ if (shouldCaptureMail(env)) {
49
+ return createMailer({ from, transport: createCaptureTransport(createCaptureSink(env, options.rootShard, options.jurisdiction)) });
50
+ }
51
+ if (options.cloudflareSend) {
52
+ return createMailer({ cloudflareSend: options.cloudflareSend, from });
53
+ }
54
+ const apiKey = typeof env["RESEND_API_KEY"] === "string" ? env["RESEND_API_KEY"] : void 0;
55
+ if (apiKey !== void 0 && apiKey !== "") {
56
+ return createMailer({ apiKey, from });
57
+ }
58
+ throw new Error(
59
+ "@lunora/mail: no transport configured — provide `cloudflareSend` (a SEND_EMAIL binding) or RESEND_API_KEY, or run in a dev environment to capture."
60
+ );
61
+ };
62
+
63
+ export { createCaptureSink, createMailerFromEnv, shouldCaptureMail };
@@ -0,0 +1,11 @@
1
+ const CAPTURE_TRANSPORT_BRAND = /* @__PURE__ */ Symbol.for("@lunora/mail.captureTransport");
2
+ const isCaptureTransport = (transport) => transport[CAPTURE_TRANSPORT_BRAND] === true;
3
+ const createCaptureTransport = (sink) => {
4
+ const transport = {
5
+ [CAPTURE_TRANSPORT_BRAND]: true,
6
+ send: async (payload) => sink.record(payload)
7
+ };
8
+ return transport;
9
+ };
10
+
11
+ export { CAPTURE_TRANSPORT_BRAND, createCaptureTransport, isCaptureTransport };
@@ -0,0 +1,26 @@
1
+ import { cloudflareEmailProvider } from '@visulima/email/providers/cloudflare-email';
2
+ import { r as requireRecipients, t as toProviderEmail, i as interpretSendResult } from './provider-transport-C5CVbjRF.mjs';
3
+
4
+ const createCloudflareTransport = (options) => {
5
+ const provider = cloudflareEmailProvider({ send: options.send });
6
+ return {
7
+ send: async (payload) => {
8
+ await provider.initialize();
9
+ const hasCc = payload.cc !== void 0 && payload.cc.length > 0;
10
+ const hasBcc = payload.bcc !== void 0 && payload.bcc.length > 0;
11
+ if (hasCc || hasBcc) {
12
+ throw new Error("@lunora/mail: Cloudflare Email Workers does not support cc/bcc — fan out one send per recipient instead");
13
+ }
14
+ const { first, list } = requireRecipients(payload.to);
15
+ if (list.length > 1) {
16
+ throw new Error(
17
+ `@lunora/mail: Cloudflare Email Workers is single-recipient but received ${String(list.length)} \`to\` addresses — fan out one send per recipient instead`
18
+ );
19
+ }
20
+ const result = await provider.sendEmail(toProviderEmail(payload, options.from, first));
21
+ return interpretSendResult(result);
22
+ }
23
+ };
24
+ };
25
+
26
+ export { createCloudflareTransport };
@@ -0,0 +1,83 @@
1
+ import { a as applyJurisdiction } from './shard-CJ-TvmfT.mjs';
2
+
3
+ const GENERIC_REJECT_REASON = "message could not be processed";
4
+ const rejectOnError = (error, context) => {
5
+ console.error("@lunora/mail/inbound: dropping message —", error);
6
+ context.message.setReject(GENERIC_REJECT_REASON);
7
+ };
8
+ const createInboundEmailHandler = (options) => {
9
+ const onError = options.onError ?? rejectOnError;
10
+ return async (message, env, context_) => {
11
+ const context = { ctx: context_, env, message };
12
+ try {
13
+ const parsed = await options.parse(message.raw);
14
+ if (options.verify) {
15
+ const verified = await options.verify(parsed, context);
16
+ if (verified === false) {
17
+ throw new Error("@lunora/mail/inbound: sender verification rejected the message");
18
+ }
19
+ }
20
+ await options.dispatch(parsed, context);
21
+ } catch (error) {
22
+ await onError(error, context);
23
+ }
24
+ };
25
+ };
26
+ const DEFAULT_ROOT_SHARD = "__root__";
27
+ const toBase64 = (bytes) => {
28
+ let binary = "";
29
+ for (const byte of bytes) {
30
+ binary += String.fromCodePoint(byte);
31
+ }
32
+ return btoa(binary);
33
+ };
34
+ const toJsonSafeEmail = (email) => {
35
+ if (email.attachments.length === 0) {
36
+ return email;
37
+ }
38
+ return {
39
+ ...email,
40
+ attachments: email.attachments.map((attachment) => {
41
+ const { content } = attachment;
42
+ if (typeof content === "string") {
43
+ return attachment;
44
+ }
45
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
46
+ return { ...attachment, content: toBase64(bytes), encoding: "base64" };
47
+ })
48
+ };
49
+ };
50
+ const dispatchToLunoraFunction = (options) => {
51
+ const shardKey = options.shardKey ?? DEFAULT_ROOT_SHARD;
52
+ const resolveArgs = options.resolveArgs ?? ((email) => toJsonSafeEmail(email));
53
+ return async (email, context) => {
54
+ const adminToken = options.adminToken ?? (typeof context.env["LUNORA_ADMIN_TOKEN"] === "string" ? context.env["LUNORA_ADMIN_TOKEN"] : void 0);
55
+ if (adminToken === void 0 || adminToken === "") {
56
+ throw new Error("@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");
57
+ }
58
+ const envelope = {
59
+ args: resolveArgs(email, context),
60
+ functionPath: options.functionPath,
61
+ shardKey
62
+ };
63
+ const namespace = applyJurisdiction(options.shard, options.jurisdiction);
64
+ const stub = namespace.get(namespace.idFromName(shardKey));
65
+ const response = await stub.fetch("https://shard.internal/rpc", {
66
+ body: JSON.stringify(envelope),
67
+ headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
68
+ method: "POST"
69
+ });
70
+ if (response.ok === false) {
71
+ throw new Error(`@lunora/mail/inbound: dispatch to \`${options.functionPath}\` failed (HTTP ${String(response.status ?? "?")}).`);
72
+ }
73
+ const body = await response.json();
74
+ if (typeof body === "object" && body !== null && "error" in body) {
75
+ const { error } = body;
76
+ if (error !== void 0 && error !== null) {
77
+ throw new Error(`@lunora/mail/inbound: dispatch to \`${options.functionPath}\` returned an error: ${JSON.stringify(error)}`);
78
+ }
79
+ }
80
+ };
81
+ };
82
+
83
+ export { createInboundEmailHandler, dispatchToLunoraFunction };
@@ -0,0 +1,77 @@
1
+ import { a as assertSafeHeaderValue, b as assertSafeAddresses } from './address-fkXxLKza.mjs';
2
+ import { isCaptureTransport } from './createCaptureTransport-Crz_8822.mjs';
3
+ import { createCloudflareTransport } from './createCloudflareTransport-yHOVEsZv.mjs';
4
+ import { toQueuedPayload } from './consumeQueuedSend-BEKOdaxU.mjs';
5
+ import renderEmail from './renderEmail-hyS1bpVP.mjs';
6
+ import createResendTransport from './createResendTransport-oNIorpzv.mjs';
7
+
8
+ const buildDefaultTransport = (options) => {
9
+ if (options.cloudflareSend) {
10
+ return createCloudflareTransport({ from: options.from, send: options.cloudflareSend });
11
+ }
12
+ if (options.apiKey) {
13
+ return createResendTransport(options.apiKey, options.from);
14
+ }
15
+ throw new Error("@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)");
16
+ };
17
+ const createMailer = (options) => {
18
+ if (!options.from) {
19
+ throw new Error("@lunora/mail: `from` is required");
20
+ }
21
+ const transport = options.transport ?? buildDefaultTransport(options);
22
+ const buildPayload = async (options_) => {
23
+ let { html } = options_;
24
+ let { text } = options_;
25
+ if (options_.react) {
26
+ const rendered = await renderEmail(options_.react);
27
+ html = html ?? rendered.html;
28
+ text = text ?? rendered.text;
29
+ }
30
+ assertSafeHeaderValue("subject", options_.subject);
31
+ if (options_.headers) {
32
+ for (const [name, value] of Object.entries(options_.headers)) {
33
+ assertSafeHeaderValue(`header name "${name}"`, name);
34
+ assertSafeHeaderValue(`header "${name}" value`, value);
35
+ }
36
+ }
37
+ const from = options_.from ?? options.from;
38
+ assertSafeAddresses({
39
+ bcc: options_.bcc,
40
+ cc: options_.cc,
41
+ from,
42
+ replyTo: options_.replyTo,
43
+ to: options_.to
44
+ });
45
+ return {
46
+ bcc: options_.bcc,
47
+ cc: options_.cc,
48
+ from,
49
+ headers: options_.headers,
50
+ html,
51
+ replyTo: options_.replyTo,
52
+ subject: options_.subject,
53
+ text,
54
+ to: options_.to
55
+ };
56
+ };
57
+ const send = async (options_) => {
58
+ const payload = await buildPayload(options_);
59
+ return transport.send(payload);
60
+ };
61
+ const queue = async (options_) => {
62
+ if (!options.queue) {
63
+ if (isCaptureTransport(transport)) {
64
+ const captured = await buildPayload(options_);
65
+ await transport.send(captured);
66
+ return { queued: true };
67
+ }
68
+ throw new Error("@lunora/mail: `queue` binding is required for mailer.queue()");
69
+ }
70
+ const payload = await buildPayload(options_);
71
+ await options.queue.send(toQueuedPayload(payload));
72
+ return { queued: true };
73
+ };
74
+ return { queue, send };
75
+ };
76
+
77
+ export { createMailer as default };
@@ -0,0 +1,16 @@
1
+ import { resendProvider } from '@visulima/email/providers/resend';
2
+ import { r as requireRecipients, t as toProviderEmail, i as interpretSendResult } from './provider-transport-C5CVbjRF.mjs';
3
+
4
+ const createResendTransport = (apiKey, defaultFrom) => {
5
+ const provider = resendProvider({ apiKey });
6
+ return {
7
+ send: async (payload) => {
8
+ await provider.initialize();
9
+ const { first, list } = requireRecipients(payload.to);
10
+ const result = await provider.sendEmail(toProviderEmail(payload, defaultFrom, list.length === 1 ? first : list));
11
+ return interpretSendResult(result);
12
+ }
13
+ };
14
+ };
15
+
16
+ export { createResendTransport as default };