@lunora/mail 0.0.0 → 1.0.0-alpha.10

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,84 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { a as applyJurisdiction } from './shard-CJ-TvmfT.mjs';
3
+
4
+ const GENERIC_REJECT_REASON = "message could not be processed";
5
+ const rejectOnError = (error, context) => {
6
+ console.error("@lunora/mail/inbound: dropping message —", error);
7
+ context.message.setReject(GENERIC_REJECT_REASON);
8
+ };
9
+ const createInboundEmailHandler = (options) => {
10
+ const onError = options.onError ?? rejectOnError;
11
+ return async (message, env, context_) => {
12
+ const context = { ctx: context_, env, message };
13
+ try {
14
+ const parsed = await options.parse(message.raw);
15
+ if (options.verify) {
16
+ const verified = await options.verify(parsed, context);
17
+ if (verified === false) {
18
+ throw new LunoraError("INTERNAL", "@lunora/mail/inbound: sender verification rejected the message");
19
+ }
20
+ }
21
+ await options.dispatch(parsed, context);
22
+ } catch (error) {
23
+ await onError(error, context);
24
+ }
25
+ };
26
+ };
27
+ const DEFAULT_ROOT_SHARD = "__root__";
28
+ const toBase64 = (bytes) => {
29
+ let binary = "";
30
+ for (const byte of bytes) {
31
+ binary += String.fromCodePoint(byte);
32
+ }
33
+ return btoa(binary);
34
+ };
35
+ const toJsonSafeEmail = (email) => {
36
+ if (email.attachments.length === 0) {
37
+ return email;
38
+ }
39
+ return {
40
+ ...email,
41
+ attachments: email.attachments.map((attachment) => {
42
+ const { content } = attachment;
43
+ if (typeof content === "string") {
44
+ return attachment;
45
+ }
46
+ const bytes = content instanceof Uint8Array ? content : new Uint8Array(content);
47
+ return { ...attachment, content: toBase64(bytes), encoding: "base64" };
48
+ })
49
+ };
50
+ };
51
+ const dispatchToLunoraFunction = (options) => {
52
+ const shardKey = options.shardKey ?? DEFAULT_ROOT_SHARD;
53
+ const resolveArgs = options.resolveArgs ?? ((email) => toJsonSafeEmail(email));
54
+ return async (email, context) => {
55
+ const adminToken = options.adminToken ?? (typeof context.env["LUNORA_ADMIN_TOKEN"] === "string" ? context.env["LUNORA_ADMIN_TOKEN"] : void 0);
56
+ if (adminToken === void 0 || adminToken === "") {
57
+ throw new LunoraError("INTERNAL", "@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");
58
+ }
59
+ const envelope = {
60
+ args: resolveArgs(email, context),
61
+ functionPath: options.functionPath,
62
+ shardKey
63
+ };
64
+ const namespace = applyJurisdiction(options.shard, options.jurisdiction);
65
+ const stub = namespace.get(namespace.idFromName(shardKey));
66
+ const response = await stub.fetch("https://shard.internal/rpc", {
67
+ body: JSON.stringify(envelope),
68
+ headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
69
+ method: "POST"
70
+ });
71
+ if (response.ok === false) {
72
+ throw new LunoraError("INTERNAL", `@lunora/mail/inbound: dispatch to \`${options.functionPath}\` failed (HTTP ${String(response.status ?? "?")}).`);
73
+ }
74
+ const body = await response.json();
75
+ if (typeof body === "object" && body !== null && "error" in body) {
76
+ const { error } = body;
77
+ if (error !== void 0 && error !== null) {
78
+ throw new LunoraError("INTERNAL", `@lunora/mail/inbound: dispatch to \`${options.functionPath}\` returned an error: ${JSON.stringify(error)}`);
79
+ }
80
+ }
81
+ };
82
+ };
83
+
84
+ export { createInboundEmailHandler, dispatchToLunoraFunction };
@@ -0,0 +1,81 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { a as assertSafeHeaderValue, b as assertSafeAddresses } from './address-vSUAVU2T.mjs';
3
+ import { isCaptureTransport } from './createCaptureTransport-Crz_8822.mjs';
4
+ import { createCloudflareTransport } from './createCloudflareTransport-DXh1nUNi.mjs';
5
+ import { toQueuedPayload } from './consumeQueuedSend-B9hTDOZ6.mjs';
6
+ import renderEmail from './renderEmail-hyS1bpVP.mjs';
7
+ import createResendTransport from './createResendTransport-CMQWjZoi.mjs';
8
+
9
+ const buildDefaultTransport = (options) => {
10
+ if (options.cloudflareSend) {
11
+ return createCloudflareTransport({ from: options.from, send: options.cloudflareSend });
12
+ }
13
+ if (options.apiKey) {
14
+ return createResendTransport(options.apiKey, options.from);
15
+ }
16
+ throw new LunoraError(
17
+ "INTERNAL",
18
+ "@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)"
19
+ );
20
+ };
21
+ const createMailer = (options) => {
22
+ if (!options.from) {
23
+ throw new LunoraError("INTERNAL", "@lunora/mail: `from` is required");
24
+ }
25
+ const transport = options.transport ?? buildDefaultTransport(options);
26
+ const buildPayload = async (options_) => {
27
+ let { html } = options_;
28
+ let { text } = options_;
29
+ if (options_.react) {
30
+ const rendered = await renderEmail(options_.react);
31
+ html = html ?? rendered.html;
32
+ text = text ?? rendered.text;
33
+ }
34
+ assertSafeHeaderValue("subject", options_.subject);
35
+ if (options_.headers) {
36
+ for (const [name, value] of Object.entries(options_.headers)) {
37
+ assertSafeHeaderValue(`header name "${name}"`, name);
38
+ assertSafeHeaderValue(`header "${name}" value`, value);
39
+ }
40
+ }
41
+ const from = options_.from ?? options.from;
42
+ assertSafeAddresses({
43
+ bcc: options_.bcc,
44
+ cc: options_.cc,
45
+ from,
46
+ replyTo: options_.replyTo,
47
+ to: options_.to
48
+ });
49
+ return {
50
+ bcc: options_.bcc,
51
+ cc: options_.cc,
52
+ from,
53
+ headers: options_.headers,
54
+ html,
55
+ replyTo: options_.replyTo,
56
+ subject: options_.subject,
57
+ text,
58
+ to: options_.to
59
+ };
60
+ };
61
+ const send = async (options_) => {
62
+ const payload = await buildPayload(options_);
63
+ return transport.send(payload);
64
+ };
65
+ const queue = async (options_) => {
66
+ if (!options.queue) {
67
+ if (isCaptureTransport(transport)) {
68
+ const captured = await buildPayload(options_);
69
+ await transport.send(captured);
70
+ return { queued: true };
71
+ }
72
+ throw new LunoraError("INTERNAL", "@lunora/mail: `queue` binding is required for mailer.queue()");
73
+ }
74
+ const payload = await buildPayload(options_);
75
+ await options.queue.send(toQueuedPayload(payload));
76
+ return { queued: true };
77
+ };
78
+ return { queue, send };
79
+ };
80
+
81
+ 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-C0xHi3oy.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 };
@@ -0,0 +1,72 @@
1
+ import PostalMime from 'postal-mime';
2
+ import { a as assertSafeHeaderValue } from './address-vSUAVU2T.mjs';
3
+
4
+ const safe = (label, value) => {
5
+ if (value === void 0) {
6
+ return void 0;
7
+ }
8
+ assertSafeHeaderValue(`inbound ${label}`, value);
9
+ return value;
10
+ };
11
+ const formatAddress = (entry) => {
12
+ if (entry.address !== void 0 && entry.address !== "") {
13
+ return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
14
+ }
15
+ if (entry.group) {
16
+ return entry.group.map((member) => member.address ?? "").filter((address) => address !== "").join(", ");
17
+ }
18
+ return entry.name ?? "";
19
+ };
20
+ const authVerdict = (authResults, method) => {
21
+ const match = new RegExp(String.raw`\b${method}=([a-zA-Z]+)`, "i").exec(authResults);
22
+ return match?.[1]?.toLowerCase() ?? null;
23
+ };
24
+ const parseAuthentication = (authResults) => {
25
+ if (authResults === void 0 || authResults === "") {
26
+ return { dkim: null, dmarc: null, spf: null };
27
+ }
28
+ return {
29
+ dkim: authVerdict(authResults, "dkim"),
30
+ dmarc: authVerdict(authResults, "dmarc"),
31
+ spf: authVerdict(authResults, "spf")
32
+ };
33
+ };
34
+ const parseInboundEmail = async (raw) => {
35
+ const parsed = await PostalMime.parse(raw);
36
+ const headers = {};
37
+ for (const header of parsed.headers) {
38
+ assertSafeHeaderValue(`inbound header \`${header.key}\``, header.value);
39
+ headers[header.key] = header.value;
40
+ }
41
+ const to = (parsed.to ?? []).map((entry) => {
42
+ const formatted = formatAddress(entry);
43
+ assertSafeHeaderValue("inbound to", formatted);
44
+ return formatted;
45
+ });
46
+ const from = parsed.from ? formatAddress(parsed.from) : "";
47
+ assertSafeHeaderValue("inbound from", from);
48
+ const attachments = parsed.attachments.map((attachment) => {
49
+ return {
50
+ content: attachment.content,
51
+ disposition: attachment.disposition,
52
+ ...attachment.encoding === void 0 ? {} : { encoding: attachment.encoding },
53
+ filename: attachment.filename,
54
+ mimeType: attachment.mimeType
55
+ };
56
+ });
57
+ return {
58
+ attachments,
59
+ authentication: parseAuthentication(headers["authentication-results"]),
60
+ from,
61
+ headers,
62
+ ...parsed.html === void 0 ? {} : { html: parsed.html },
63
+ ...safe("inReplyTo", parsed.inReplyTo) === void 0 ? {} : { inReplyTo: parsed.inReplyTo },
64
+ ...safe("messageId", parsed.messageId) === void 0 ? {} : { messageId: parsed.messageId },
65
+ ...safe("references", parsed.references) === void 0 ? {} : { references: parsed.references },
66
+ ...safe("subject", parsed.subject) === void 0 ? {} : { subject: parsed.subject },
67
+ ...parsed.text === void 0 ? {} : { text: parsed.text },
68
+ to
69
+ };
70
+ };
71
+
72
+ export { parseInboundEmail };
@@ -0,0 +1,48 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { t as toAddressList, c as toAddress } from './address-vSUAVU2T.mjs';
3
+
4
+ const reasonOf = (rawError) => {
5
+ if (rawError instanceof Error) {
6
+ return rawError.message;
7
+ }
8
+ if (rawError === null || rawError === void 0) {
9
+ return "send failed";
10
+ }
11
+ if (typeof rawError === "string") {
12
+ return rawError;
13
+ }
14
+ if (typeof rawError === "number" || typeof rawError === "boolean" || typeof rawError === "bigint") {
15
+ return rawError.toString();
16
+ }
17
+ return JSON.stringify(rawError) ?? "send failed";
18
+ };
19
+ const requireRecipients = (to) => {
20
+ const list = toAddressList(to);
21
+ const [first] = list ?? [];
22
+ if (!list || first === void 0) {
23
+ throw new LunoraError("INTERNAL", "@lunora/mail: at least one recipient is required");
24
+ }
25
+ return { first, list };
26
+ };
27
+ const toProviderEmail = (payload, defaultFrom, to) => {
28
+ return {
29
+ bcc: toAddressList(payload.bcc),
30
+ cc: toAddressList(payload.cc),
31
+ from: toAddress(payload.from ?? defaultFrom),
32
+ headers: payload.headers,
33
+ html: payload.html,
34
+ replyTo: payload.replyTo ? toAddress(payload.replyTo) : void 0,
35
+ subject: payload.subject,
36
+ text: payload.text,
37
+ to
38
+ };
39
+ };
40
+ const interpretSendResult = (result) => {
41
+ if (!result.success || !result.data) {
42
+ console.error(`@lunora/mail: send failed: ${reasonOf(result.error)}`);
43
+ throw new LunoraError("INTERNAL", "@lunora/mail: send failed");
44
+ }
45
+ return { id: result.data.messageId };
46
+ };
47
+
48
+ export { interpretSendResult as i, requireRecipients as r, toProviderEmail as t };
@@ -0,0 +1,8 @@
1
+ import { render } from '@react-email/render';
2
+
3
+ const renderEmail = async (element) => {
4
+ const [html, text] = await Promise.all([render(element, { pretty: false }), render(element, { plainText: true })]);
5
+ return { html, text };
6
+ };
7
+
8
+ export { renderEmail as default };
@@ -0,0 +1,13 @@
1
+ const applyJurisdiction = (namespace, jurisdiction) => {
2
+ if (jurisdiction === void 0) {
3
+ return namespace;
4
+ }
5
+ if (typeof namespace.jurisdiction !== "function") {
6
+ throw new TypeError(
7
+ `@lunora/mail: Durable Object namespace does not support jurisdiction("${jurisdiction}") — update @cloudflare/workers-types or remove the jurisdiction option`
8
+ );
9
+ }
10
+ return namespace.jurisdiction(jurisdiction);
11
+ };
12
+
13
+ export { applyJurisdiction as a };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structural projections of the `SHARD` Durable Object namespace + one shard
3
+ * stub, shared by the inbound dispatcher. Mirrors the shapes the outbound dev
4
+ * capture sink uses (`packages/mail/src/from-env.ts`) so inbound dispatch routes
5
+ * a parsed message into a Lunora function over the exact same admin-RPC-over-shard
6
+ * path — without importing any Cloudflare types into `@lunora/mail`.
7
+ */
8
+ /** Structural projection of one shard stub — only `fetch` returning something with `.json()`. */
9
+ interface ShardStubLike {
10
+ fetch: (input: string, init?: {
11
+ body?: string;
12
+ headers?: Record<string, string>;
13
+ method?: string;
14
+ }) => Promise<{
15
+ json: () => Promise<unknown>;
16
+ }>;
17
+ }
18
+ /**
19
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
20
+ * Cloudflare adds values over time.
21
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
22
+ */
23
+ type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
24
+ /** Structural projection of the `SHARD` Durable Object namespace. */
25
+ interface ShardNamespaceLike {
26
+ get: (id: unknown) => ShardStubLike;
27
+ idFromName: (name: string) => unknown;
28
+ /**
29
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
30
+ * workers-types releases (and test doubles) may not expose it.
31
+ */
32
+ jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ShardNamespaceLike;
33
+ }
34
+ /**
35
+ * Return a jurisdiction-restricted view of `namespace`, or `namespace`
36
+ * unchanged when no jurisdiction is configured. Fail-closed when the binding
37
+ * lacks `.jurisdiction()` so a residency constraint is never silently dropped.
38
+ */
39
+ export { DurableObjectJurisdiction as D, ShardNamespaceLike as S, ShardStubLike as a };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Structural projections of the `SHARD` Durable Object namespace + one shard
3
+ * stub, shared by the inbound dispatcher. Mirrors the shapes the outbound dev
4
+ * capture sink uses (`packages/mail/src/from-env.ts`) so inbound dispatch routes
5
+ * a parsed message into a Lunora function over the exact same admin-RPC-over-shard
6
+ * path — without importing any Cloudflare types into `@lunora/mail`.
7
+ */
8
+ /** Structural projection of one shard stub — only `fetch` returning something with `.json()`. */
9
+ interface ShardStubLike {
10
+ fetch: (input: string, init?: {
11
+ body?: string;
12
+ headers?: Record<string, string>;
13
+ method?: string;
14
+ }) => Promise<{
15
+ json: () => Promise<unknown>;
16
+ }>;
17
+ }
18
+ /**
19
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
20
+ * Cloudflare adds values over time.
21
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
22
+ */
23
+ type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
24
+ /** Structural projection of the `SHARD` Durable Object namespace. */
25
+ interface ShardNamespaceLike {
26
+ get: (id: unknown) => ShardStubLike;
27
+ idFromName: (name: string) => unknown;
28
+ /**
29
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
30
+ * workers-types releases (and test doubles) may not expose it.
31
+ */
32
+ jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ShardNamespaceLike;
33
+ }
34
+ /**
35
+ * Return a jurisdiction-restricted view of `namespace`, or `namespace`
36
+ * unchanged when no jurisdiction is configured. Fail-closed when the binding
37
+ * lacks `.jurisdiction()` so a residency constraint is never silently dropped.
38
+ */
39
+ export { DurableObjectJurisdiction as D, ShardNamespaceLike as S, ShardStubLike as a };
@@ -0,0 +1,49 @@
1
+ import { C as CapturedMail } from "./packem_shared/capture-transport.d-ChnhdPO2.mjs";
2
+ import 'react';
3
+ /** Minimal `fetch` projection so a test can inject a stub. */
4
+ type FetchLike = (input: string, init?: {
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ method?: string;
8
+ }) => Promise<{
9
+ json: () => Promise<unknown>;
10
+ ok: boolean;
11
+ status: number;
12
+ }>;
13
+ interface InboxOptions {
14
+ /** Admin bearer token (`LUNORA_ADMIN_TOKEN`) the worker gates introspection behind. */
15
+ adminToken: string;
16
+ /** App base URL, e.g. `http://localhost:8787`. */
17
+ baseUrl: string;
18
+ /** Inject a `fetch` implementation (defaults to the global). */
19
+ fetch?: FetchLike;
20
+ /** Newest-N to read (default 50). */
21
+ limit?: number;
22
+ }
23
+ interface WaitForMailOptions extends InboxOptions {
24
+ /** Poll interval in ms (default 250). */
25
+ pollMs?: number;
26
+ /** Only match a message whose subject contains this substring. */
27
+ subjectMatch?: string;
28
+ /** Give up after this many ms (default 10000). */
29
+ timeoutMs?: number;
30
+ /** Recipient address the message must be addressed to. */
31
+ to: string;
32
+ }
33
+ /** Read the captured-mail inbox (newest first). */
34
+ declare const listCapturedMail: (options: InboxOptions) => Promise<CapturedMail[]>;
35
+ /**
36
+ * Poll the captured-mail inbox until a message addressed to `to` (optionally
37
+ * matching `subjectMatch`) appears, then return it. Throws on timeout. Entries
38
+ * are newest-first, so the most recent matching message wins.
39
+ */
40
+ declare const waitForMail: (options: WaitForMailOptions) => Promise<CapturedMail>;
41
+ /**
42
+ * Pull the first link out of a captured message — html first, then text. Pass
43
+ * `match` to require the URL contain a substring (e.g. `"/reset-password"`),
44
+ * which disambiguates the action link from a logo/footer URL.
45
+ */
46
+ declare const extractLink: (mail: CapturedMail, options?: {
47
+ match?: string;
48
+ }) => string;
49
+ export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail };
@@ -0,0 +1,49 @@
1
+ import { C as CapturedMail } from "./packem_shared/capture-transport.d-ChnhdPO2.js";
2
+ import 'react';
3
+ /** Minimal `fetch` projection so a test can inject a stub. */
4
+ type FetchLike = (input: string, init?: {
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ method?: string;
8
+ }) => Promise<{
9
+ json: () => Promise<unknown>;
10
+ ok: boolean;
11
+ status: number;
12
+ }>;
13
+ interface InboxOptions {
14
+ /** Admin bearer token (`LUNORA_ADMIN_TOKEN`) the worker gates introspection behind. */
15
+ adminToken: string;
16
+ /** App base URL, e.g. `http://localhost:8787`. */
17
+ baseUrl: string;
18
+ /** Inject a `fetch` implementation (defaults to the global). */
19
+ fetch?: FetchLike;
20
+ /** Newest-N to read (default 50). */
21
+ limit?: number;
22
+ }
23
+ interface WaitForMailOptions extends InboxOptions {
24
+ /** Poll interval in ms (default 250). */
25
+ pollMs?: number;
26
+ /** Only match a message whose subject contains this substring. */
27
+ subjectMatch?: string;
28
+ /** Give up after this many ms (default 10000). */
29
+ timeoutMs?: number;
30
+ /** Recipient address the message must be addressed to. */
31
+ to: string;
32
+ }
33
+ /** Read the captured-mail inbox (newest first). */
34
+ declare const listCapturedMail: (options: InboxOptions) => Promise<CapturedMail[]>;
35
+ /**
36
+ * Poll the captured-mail inbox until a message addressed to `to` (optionally
37
+ * matching `subjectMatch`) appears, then return it. Throws on timeout. Entries
38
+ * are newest-first, so the most recent matching message wins.
39
+ */
40
+ declare const waitForMail: (options: WaitForMailOptions) => Promise<CapturedMail>;
41
+ /**
42
+ * Pull the first link out of a captured message — html first, then text. Pass
43
+ * `match` to require the URL contain a substring (e.g. `"/reset-password"`),
44
+ * which disambiguates the action link from a logo/footer URL.
45
+ */
46
+ declare const extractLink: (mail: CapturedMail, options?: {
47
+ match?: string;
48
+ }) => string;
49
+ export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail };
@@ -0,0 +1,63 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
3
+ const GET_CAPTURED_MAIL_OP = "__lunora_admin__:getCapturedMail";
4
+ const DEFAULT_RPC_PATH = "/_lunora/rpc";
5
+ const TRAILING_SLASH = /\/$/;
6
+ const sleep = async (ms) => new Promise((resolve) => {
7
+ setTimeout(resolve, ms);
8
+ });
9
+ const recipients = (mail) => Array.isArray(mail.to) ? mail.to : [mail.to];
10
+ const listCapturedMail = async (options) => {
11
+ const fetchImpl = options.fetch ?? globalThis.fetch;
12
+ const endpoint = `${options.baseUrl.replace(TRAILING_SLASH, "")}${DEFAULT_RPC_PATH}`;
13
+ const response = await fetchImpl(endpoint, {
14
+ body: JSON.stringify({ args: { limit: options.limit ?? 50 }, functionPath: GET_CAPTURED_MAIL_OP }),
15
+ headers: { authorization: `Bearer ${options.adminToken}`, "content-type": "application/json" },
16
+ method: "POST"
17
+ });
18
+ if (!response.ok) {
19
+ throw new LunoraError("INTERNAL", `@lunora/mail/testing: getCapturedMail failed (HTTP ${String(response.status)})`);
20
+ }
21
+ const body = await response.json();
22
+ return body.result?.entries ?? [];
23
+ };
24
+ const waitForMail = async (options) => {
25
+ const timeoutMs = options.timeoutMs ?? 1e4;
26
+ const pollMs = options.pollMs ?? 250;
27
+ const deadline = Date.now() + timeoutMs;
28
+ for (; ; ) {
29
+ const entries = await listCapturedMail(options);
30
+ const match = entries.find(
31
+ (mail) => recipients(mail).includes(options.to) && (options.subjectMatch === void 0 || mail.subject.includes(options.subjectMatch))
32
+ );
33
+ if (match) {
34
+ return match;
35
+ }
36
+ if (Date.now() >= deadline) {
37
+ throw new LunoraError(
38
+ "INTERNAL",
39
+ `@lunora/mail/testing: no mail to "${options.to}"${options.subjectMatch === void 0 ? "" : ` matching "${options.subjectMatch}"`} within ${String(timeoutMs)}ms`
40
+ );
41
+ }
42
+ await sleep(pollMs);
43
+ }
44
+ };
45
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)]+/g;
46
+ const extractLink = (mail, options = {}) => {
47
+ for (const source of [mail.html, mail.text]) {
48
+ if (source === void 0) {
49
+ continue;
50
+ }
51
+ const matches = source.match(URL_PATTERN) ?? [];
52
+ const link = matches.find((candidate) => options.match === void 0 || candidate.includes(options.match));
53
+ if (link !== void 0) {
54
+ return link;
55
+ }
56
+ }
57
+ throw new LunoraError(
58
+ "INTERNAL",
59
+ `@lunora/mail/testing: no link${options.match === void 0 ? "" : ` containing "${options.match}"`} found in the captured message`
60
+ );
61
+ };
62
+
63
+ export { extractLink, listCapturedMail, waitForMail };
package/package.json CHANGED
@@ -1,31 +1,73 @@
1
1
  {
2
2
  "name": "@lunora/mail",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-alpha.10",
4
4
  "description": "Email for Lunora: Resend adapter, TSX templates, and queue-backed sends",
5
- "license": "FSL-1.1-Apache-2.0",
6
- "homepage": "https://lunora.sh",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/anolilab/lunora.git",
10
- "directory": "packages/mail"
11
- },
12
- "bugs": {
13
- "url": "https://github.com/anolilab/lunora/issues"
14
- },
15
5
  "keywords": [
16
- "lunora",
17
6
  "cloudflare",
18
- "workers",
19
7
  "durable-objects",
20
8
  "email",
9
+ "lunora",
21
10
  "mail",
11
+ "react-email",
22
12
  "resend",
23
- "react-email"
13
+ "workers"
24
14
  ],
15
+ "homepage": "https://lunora.sh",
16
+ "bugs": "https://github.com/anolilab/lunora/issues",
17
+ "license": "FSL-1.1-Apache-2.0",
18
+ "author": {
19
+ "name": "Daniel Bannert",
20
+ "email": "d.bannert@anolilab.de"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/anolilab/lunora.git",
25
+ "directory": "packages/mail"
26
+ },
27
+ "files": [
28
+ "./dist",
29
+ "__assets__",
30
+ "README.md",
31
+ "LICENSE.md"
32
+ ],
33
+ "type": "module",
34
+ "sideEffects": false,
35
+ "main": "./dist/index.mjs",
36
+ "module": "./dist/index.mjs",
37
+ "types": "./dist/index.d.ts",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.mjs"
42
+ },
43
+ "./inbound": {
44
+ "types": "./dist/inbound/index.d.ts",
45
+ "import": "./dist/inbound/index.mjs"
46
+ },
47
+ "./testing": {
48
+ "types": "./dist/testing.d.ts",
49
+ "import": "./dist/testing.mjs"
50
+ },
51
+ "./package.json": "./package.json"
52
+ },
25
53
  "publishConfig": {
26
54
  "access": "public"
27
55
  },
28
- "files": [
29
- "README.md"
30
- ]
56
+ "dependencies": {
57
+ "@lunora/errors": "1.0.0-alpha.1",
58
+ "@react-email/render": "2.0.10",
59
+ "@visulima/email": "1.0.0-alpha.41",
60
+ "postal-mime": "2.7.5"
61
+ },
62
+ "peerDependencies": {
63
+ "react": "^19.2.7"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "react": {
67
+ "optional": true
68
+ }
69
+ },
70
+ "engines": {
71
+ "node": "^22.15.0 || >=24.11.0"
72
+ }
31
73
  }