@lunora/mail 0.0.0 → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ import { t as toAddressList, c as toAddress } from './address-fkXxLKza.mjs';
2
+
3
+ const reasonOf = (rawError) => {
4
+ if (rawError instanceof Error) {
5
+ return rawError.message;
6
+ }
7
+ if (rawError === null || rawError === void 0) {
8
+ return "send failed";
9
+ }
10
+ if (typeof rawError === "string") {
11
+ return rawError;
12
+ }
13
+ if (typeof rawError === "number" || typeof rawError === "boolean" || typeof rawError === "bigint") {
14
+ return rawError.toString();
15
+ }
16
+ return JSON.stringify(rawError) ?? "send failed";
17
+ };
18
+ const requireRecipients = (to) => {
19
+ const list = toAddressList(to);
20
+ const [first] = list ?? [];
21
+ if (!list || first === void 0) {
22
+ throw new Error("@lunora/mail: at least one recipient is required");
23
+ }
24
+ return { first, list };
25
+ };
26
+ const toProviderEmail = (payload, defaultFrom, to) => {
27
+ return {
28
+ bcc: toAddressList(payload.bcc),
29
+ cc: toAddressList(payload.cc),
30
+ from: toAddress(payload.from ?? defaultFrom),
31
+ headers: payload.headers,
32
+ html: payload.html,
33
+ replyTo: payload.replyTo ? toAddress(payload.replyTo) : void 0,
34
+ subject: payload.subject,
35
+ text: payload.text,
36
+ to
37
+ };
38
+ };
39
+ const interpretSendResult = (result) => {
40
+ if (!result.success || !result.data) {
41
+ console.error(`@lunora/mail: send failed: ${reasonOf(result.error)}`);
42
+ throw new Error("@lunora/mail: send failed");
43
+ }
44
+ return { id: result.data.messageId };
45
+ };
46
+
47
+ 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,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,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,57 @@
1
+ const GET_CAPTURED_MAIL_OP = "__lunora_admin__:getCapturedMail";
2
+ const DEFAULT_RPC_PATH = "/_lunora/rpc";
3
+ const TRAILING_SLASH = /\/$/;
4
+ const sleep = async (ms) => new Promise((resolve) => {
5
+ setTimeout(resolve, ms);
6
+ });
7
+ const recipients = (mail) => Array.isArray(mail.to) ? mail.to : [mail.to];
8
+ const listCapturedMail = async (options) => {
9
+ const fetchImpl = options.fetch ?? globalThis.fetch;
10
+ const endpoint = `${options.baseUrl.replace(TRAILING_SLASH, "")}${DEFAULT_RPC_PATH}`;
11
+ const response = await fetchImpl(endpoint, {
12
+ body: JSON.stringify({ args: { limit: options.limit ?? 50 }, functionPath: GET_CAPTURED_MAIL_OP }),
13
+ headers: { authorization: `Bearer ${options.adminToken}`, "content-type": "application/json" },
14
+ method: "POST"
15
+ });
16
+ if (!response.ok) {
17
+ throw new Error(`@lunora/mail/testing: getCapturedMail failed (HTTP ${String(response.status)})`);
18
+ }
19
+ const body = await response.json();
20
+ return body.result?.entries ?? [];
21
+ };
22
+ const waitForMail = async (options) => {
23
+ const timeoutMs = options.timeoutMs ?? 1e4;
24
+ const pollMs = options.pollMs ?? 250;
25
+ const deadline = Date.now() + timeoutMs;
26
+ for (; ; ) {
27
+ const entries = await listCapturedMail(options);
28
+ const match = entries.find(
29
+ (mail) => recipients(mail).includes(options.to) && (options.subjectMatch === void 0 || mail.subject.includes(options.subjectMatch))
30
+ );
31
+ if (match) {
32
+ return match;
33
+ }
34
+ if (Date.now() >= deadline) {
35
+ throw new Error(
36
+ `@lunora/mail/testing: no mail to "${options.to}"${options.subjectMatch === void 0 ? "" : ` matching "${options.subjectMatch}"`} within ${String(timeoutMs)}ms`
37
+ );
38
+ }
39
+ await sleep(pollMs);
40
+ }
41
+ };
42
+ const URL_PATTERN = /https?:\/\/[^\s"'<>)]+/g;
43
+ const extractLink = (mail, options = {}) => {
44
+ for (const source of [mail.html, mail.text]) {
45
+ if (source === void 0) {
46
+ continue;
47
+ }
48
+ const matches = source.match(URL_PATTERN) ?? [];
49
+ const link = matches.find((candidate) => options.match === void 0 || candidate.includes(options.match));
50
+ if (link !== void 0) {
51
+ return link;
52
+ }
53
+ }
54
+ throw new Error(`@lunora/mail/testing: no link${options.match === void 0 ? "" : ` containing "${options.match}"`} found in the captured message`);
55
+ };
56
+
57
+ export { extractLink, listCapturedMail, waitForMail };
package/package.json CHANGED
@@ -1,31 +1,72 @@
1
1
  {
2
2
  "name": "@lunora/mail",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-alpha.1",
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
+ "@react-email/render": "2.0.9",
58
+ "@visulima/email": "1.0.0-alpha.41",
59
+ "postal-mime": "2.7.4"
60
+ },
61
+ "peerDependencies": {
62
+ "react": "^19.2.7"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "react": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "engines": {
70
+ "node": "^22.15.0 || >=24.11.0"
71
+ }
31
72
  }