@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.
- package/LICENSE.md +105 -0
- package/README.md +117 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/inbound/index.d.mts +193 -0
- package/dist/inbound/index.d.ts +193 -0
- package/dist/inbound/index.mjs +2 -0
- package/dist/index.d.mts +133 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.mjs +7 -0
- package/dist/packem_shared/address-fkXxLKza.mjs +62 -0
- package/dist/packem_shared/capture-transport.d-ChnhdPO2.d.mts +117 -0
- package/dist/packem_shared/capture-transport.d-ChnhdPO2.d.ts +117 -0
- package/dist/packem_shared/consumeQueuedSend-BEKOdaxU.mjs +75 -0
- package/dist/packem_shared/createCaptureSink-DeihS4LH.mjs +63 -0
- package/dist/packem_shared/createCaptureTransport-Crz_8822.mjs +11 -0
- package/dist/packem_shared/createCloudflareTransport-yHOVEsZv.mjs +26 -0
- package/dist/packem_shared/createInboundEmailHandler-D0uCOrU-.mjs +83 -0
- package/dist/packem_shared/createMailer-oEKPAd4J.mjs +77 -0
- package/dist/packem_shared/createResendTransport-oNIorpzv.mjs +16 -0
- package/dist/packem_shared/parseInboundEmail-Bw9u_1oc.mjs +72 -0
- package/dist/packem_shared/provider-transport-C5CVbjRF.mjs +47 -0
- package/dist/packem_shared/renderEmail-hyS1bpVP.mjs +8 -0
- package/dist/packem_shared/shard-CJ-TvmfT.mjs +13 -0
- package/dist/packem_shared/shard.d-CL2Lmliv.d.mts +39 -0
- package/dist/packem_shared/shard.d-CL2Lmliv.d.ts +39 -0
- package/dist/testing.d.mts +49 -0
- package/dist/testing.d.ts +49 -0
- package/dist/testing.mjs +57 -0
- package/package.json +58 -17
|
@@ -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 <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 <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 };
|