@lunora/mail 1.0.0-alpha.4 → 1.0.0-alpha.6

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.
@@ -1,2 +1,2 @@
1
- export { createInboundEmailHandler, dispatchToLunoraFunction } from '../packem_shared/createInboundEmailHandler-D0uCOrU-.mjs';
2
- export { parseInboundEmail } from '../packem_shared/parseInboundEmail-Bw9u_1oc.mjs';
1
+ export { createInboundEmailHandler, dispatchToLunoraFunction } from '../packem_shared/createInboundEmailHandler-BW_s0-Vc.mjs';
2
+ export { parseInboundEmail } from '../packem_shared/parseInboundEmail-6lafQBT1.mjs';
package/dist/index.d.mts CHANGED
@@ -34,6 +34,23 @@ interface CloudflareTransportOptions {
34
34
  * verified-address constraint never bites the dev loop.
35
35
  */
36
36
  declare const createCloudflareTransport: (options: CloudflareTransportOptions) => MailTransport;
37
+ /**
38
+ * Create a mailer bound to a transport.
39
+ *
40
+ * SECURITY — recipient policy and HTML content are the caller's responsibility.
41
+ * The mailer fully blocks header/CRLF/comma injection in addresses
42
+ * (`assertSafeAddresses` / `assertSafeHeaderValue`), but it does NOT decide WHO
43
+ * you may send to or WHAT HTML you render.
44
+ *
45
+ * Open relay: derive `to`/`cc`/`bcc` from server-trusted state, never from raw
46
+ * request input, and prefer a fixed/allowlisted `from` — sending to an arbitrary
47
+ * user-supplied address turns your deployment into a spam relay.
48
+ *
49
+ * Template XSS / content injection: treat template HTML like any other HTML sink
50
+ * — never interpolate untrusted data into raw markup (or a
51
+ * `dangerouslySetInnerHTML`-style template) without escaping. The mailer sends
52
+ * whatever HTML you hand it verbatim.
53
+ */
37
54
  declare const createMailer: (options: LunoraMailOptions) => Mailer;
38
55
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
39
56
  type MailEnv = Record<string, unknown>;
package/dist/index.d.ts CHANGED
@@ -34,6 +34,23 @@ interface CloudflareTransportOptions {
34
34
  * verified-address constraint never bites the dev loop.
35
35
  */
36
36
  declare const createCloudflareTransport: (options: CloudflareTransportOptions) => MailTransport;
37
+ /**
38
+ * Create a mailer bound to a transport.
39
+ *
40
+ * SECURITY — recipient policy and HTML content are the caller's responsibility.
41
+ * The mailer fully blocks header/CRLF/comma injection in addresses
42
+ * (`assertSafeAddresses` / `assertSafeHeaderValue`), but it does NOT decide WHO
43
+ * you may send to or WHAT HTML you render.
44
+ *
45
+ * Open relay: derive `to`/`cc`/`bcc` from server-trusted state, never from raw
46
+ * request input, and prefer a fixed/allowlisted `from` — sending to an arbitrary
47
+ * user-supplied address turns your deployment into a spam relay.
48
+ *
49
+ * Template XSS / content injection: treat template HTML like any other HTML sink
50
+ * — never interpolate untrusted data into raw markup (or a
51
+ * `dangerouslySetInnerHTML`-style template) without escaping. The mailer sends
52
+ * whatever HTML you hand it verbatim.
53
+ */
37
54
  declare const createMailer: (options: LunoraMailOptions) => Mailer;
38
55
  /** A Worker `env` projected as a plain record (vars, secrets, and bindings are `unknown`-valued). */
39
56
  type MailEnv = Record<string, unknown>;
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createCaptureTransport } from './packem_shared/createCaptureTransport-Crz_8822.mjs';
2
- export { createCloudflareTransport } from './packem_shared/createCloudflareTransport-yHOVEsZv.mjs';
3
- export { default as createMailer } from './packem_shared/createMailer-oEKPAd4J.mjs';
4
- export { createCaptureSink, createMailerFromEnv, shouldCaptureMail } from './packem_shared/createCaptureSink-DeihS4LH.mjs';
5
- export { consumeQueuedSend, toQueuedPayload } from './packem_shared/consumeQueuedSend-BEKOdaxU.mjs';
2
+ export { createCloudflareTransport } from './packem_shared/createCloudflareTransport-DXh1nUNi.mjs';
3
+ export { default as createMailer } from './packem_shared/createMailer-B4Z_Cgiu.mjs';
4
+ export { createCaptureSink, createMailerFromEnv, shouldCaptureMail } from './packem_shared/createCaptureSink-Di-5AJUs.mjs';
5
+ export { consumeQueuedSend, toQueuedPayload } from './packem_shared/consumeQueuedSend-B9hTDOZ6.mjs';
6
6
  export { default as renderEmail } from './packem_shared/renderEmail-hyS1bpVP.mjs';
7
- export { default as createResendTransport } from './packem_shared/createResendTransport-oNIorpzv.mjs';
7
+ export { default as createResendTransport } from './packem_shared/createResendTransport-CMQWjZoi.mjs';
@@ -1,22 +1,24 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const MAX_EMAIL_LENGTH = 320;
2
4
  const MAX_NAME_LENGTH = 256;
3
5
  const ADDRESS_PATTERN = /^([^<]*)<([^>]*)>\s*$/;
4
6
  const assertSafeAddressField = (field, value) => {
5
7
  if (value.includes("\r") || value.includes("\n") || value.includes(",")) {
6
- throw new Error(`@lunora/mail: address ${field} must not contain CR, LF, or comma`);
8
+ throw new LunoraError("INTERNAL", `@lunora/mail: address ${field} must not contain CR, LF, or comma`);
7
9
  }
8
10
  };
9
11
  const assertSafeHeaderValue = (label, value) => {
10
12
  if (value.includes("\r") || value.includes("\n")) {
11
- throw new Error(`@lunora/mail: ${label} must not contain CR or LF`);
13
+ throw new LunoraError("INTERNAL", `@lunora/mail: ${label} must not contain CR or LF`);
12
14
  }
13
15
  };
14
16
  const toBracketedAddress = (name, email) => {
15
17
  if (name.length > MAX_NAME_LENGTH) {
16
- throw new Error(`@lunora/mail: address name must be <= ${String(MAX_NAME_LENGTH)} characters`);
18
+ throw new LunoraError("INTERNAL", `@lunora/mail: address name must be <= ${String(MAX_NAME_LENGTH)} characters`);
17
19
  }
18
20
  if (email.length > MAX_EMAIL_LENGTH) {
19
- throw new Error(`@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
21
+ throw new LunoraError("INTERNAL", `@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
20
22
  }
21
23
  if (name) {
22
24
  assertSafeAddressField("name", name);
@@ -27,7 +29,7 @@ const toBracketedAddress = (name, email) => {
27
29
  const toBareAddress = (input) => {
28
30
  const email = input.trim();
29
31
  if (email.length > MAX_EMAIL_LENGTH) {
30
- throw new Error(`@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
32
+ throw new LunoraError("INTERNAL", `@lunora/mail: address email must be <= ${String(MAX_EMAIL_LENGTH)} characters`);
31
33
  }
32
34
  assertSafeAddressField("email", email);
33
35
  return { email };
@@ -1,3 +1,5 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const toQueuedPayload = (options) => {
2
4
  return {
3
5
  bcc: options.bcc,
@@ -13,7 +15,7 @@ const toQueuedPayload = (options) => {
13
15
  };
14
16
  const consumeQueuedSend = async (mailer, payload) => {
15
17
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
16
- throw new Error("@lunora/mail: queue message body must be an object");
18
+ throw new LunoraError("INTERNAL", "@lunora/mail: queue message body must be an object");
17
19
  }
18
20
  const candidate = payload;
19
21
  if (typeof candidate.subject !== "string") {
@@ -22,7 +24,7 @@ const consumeQueuedSend = async (mailer, payload) => {
22
24
  const recipientIsString = typeof candidate.to === "string";
23
25
  const recipientIsStringArray = Array.isArray(candidate.to) && candidate.to.every((value) => typeof value === "string");
24
26
  if (!recipientIsString && !recipientIsStringArray) {
25
- throw new Error("@lunora/mail: queue message `to` must be a string or string[]");
27
+ throw new LunoraError("INTERNAL", "@lunora/mail: queue message `to` must be a string or string[]");
26
28
  }
27
29
  const assertOptionalString = (field, value) => {
28
30
  if (value === void 0) {
@@ -1,5 +1,6 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { createCaptureTransport } from './createCaptureTransport-Crz_8822.mjs';
2
- import createMailer from './createMailer-oEKPAd4J.mjs';
3
+ import createMailer from './createMailer-B4Z_Cgiu.mjs';
3
4
  import { a as applyJurisdiction } from './shard-CJ-TvmfT.mjs';
4
5
 
5
6
  const RECORD_MAIL_OP = "__lunora_admin__:recordMail";
@@ -9,7 +10,7 @@ const ENVIRONMENT_VARS = ["CF_ENV", "ENVIRONMENT", "NODE_ENV", "WORKER_ENV"];
9
10
  const requireStringEnv = (env, name) => {
10
11
  const value = env[name];
11
12
  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
+ throw new LunoraError("INTERNAL", `@lunora/mail: missing env var \`${name}\` — set it in .dev.vars (and \`wrangler secret put ${name}\` for secrets).`);
13
14
  }
14
15
  return value;
15
16
  };
@@ -55,7 +56,8 @@ const createMailerFromEnv = (env, options = {}) => {
55
56
  if (apiKey !== void 0 && apiKey !== "") {
56
57
  return createMailer({ apiKey, from });
57
58
  }
58
- throw new Error(
59
+ throw new LunoraError(
60
+ "INTERNAL",
59
61
  "@lunora/mail: no transport configured — provide `cloudflareSend` (a SEND_EMAIL binding) or RESEND_API_KEY, or run in a dev environment to capture."
60
62
  );
61
63
  };
@@ -1,5 +1,6 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  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
+ import { r as requireRecipients, t as toProviderEmail, i as interpretSendResult } from './provider-transport-C0xHi3oy.mjs';
3
4
 
4
5
  const createCloudflareTransport = (options) => {
5
6
  const provider = cloudflareEmailProvider({ send: options.send });
@@ -9,11 +10,12 @@ const createCloudflareTransport = (options) => {
9
10
  const hasCc = payload.cc !== void 0 && payload.cc.length > 0;
10
11
  const hasBcc = payload.bcc !== void 0 && payload.bcc.length > 0;
11
12
  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
+ throw new LunoraError("INTERNAL", "@lunora/mail: Cloudflare Email Workers does not support cc/bcc — fan out one send per recipient instead");
13
14
  }
14
15
  const { first, list } = requireRecipients(payload.to);
15
16
  if (list.length > 1) {
16
- throw new Error(
17
+ throw new LunoraError(
18
+ "INTERNAL",
17
19
  `@lunora/mail: Cloudflare Email Workers is single-recipient but received ${String(list.length)} \`to\` addresses — fan out one send per recipient instead`
18
20
  );
19
21
  }
@@ -1,3 +1,4 @@
1
+ import { LunoraError } from '@lunora/errors';
1
2
  import { a as applyJurisdiction } from './shard-CJ-TvmfT.mjs';
2
3
 
3
4
  const GENERIC_REJECT_REASON = "message could not be processed";
@@ -14,7 +15,7 @@ const createInboundEmailHandler = (options) => {
14
15
  if (options.verify) {
15
16
  const verified = await options.verify(parsed, context);
16
17
  if (verified === false) {
17
- throw new Error("@lunora/mail/inbound: sender verification rejected the message");
18
+ throw new LunoraError("INTERNAL", "@lunora/mail/inbound: sender verification rejected the message");
18
19
  }
19
20
  }
20
21
  await options.dispatch(parsed, context);
@@ -53,7 +54,7 @@ const dispatchToLunoraFunction = (options) => {
53
54
  return async (email, context) => {
54
55
  const adminToken = options.adminToken ?? (typeof context.env["LUNORA_ADMIN_TOKEN"] === "string" ? context.env["LUNORA_ADMIN_TOKEN"] : void 0);
55
56
  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
+ throw new LunoraError("INTERNAL", "@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");
57
58
  }
58
59
  const envelope = {
59
60
  args: resolveArgs(email, context),
@@ -68,13 +69,13 @@ const dispatchToLunoraFunction = (options) => {
68
69
  method: "POST"
69
70
  });
70
71
  if (response.ok === false) {
71
- throw new Error(`@lunora/mail/inbound: dispatch to \`${options.functionPath}\` failed (HTTP ${String(response.status ?? "?")}).`);
72
+ throw new LunoraError("INTERNAL", `@lunora/mail/inbound: dispatch to \`${options.functionPath}\` failed (HTTP ${String(response.status ?? "?")}).`);
72
73
  }
73
74
  const body = await response.json();
74
75
  if (typeof body === "object" && body !== null && "error" in body) {
75
76
  const { error } = body;
76
77
  if (error !== void 0 && error !== null) {
77
- throw new Error(`@lunora/mail/inbound: dispatch to \`${options.functionPath}\` returned an error: ${JSON.stringify(error)}`);
78
+ throw new LunoraError("INTERNAL", `@lunora/mail/inbound: dispatch to \`${options.functionPath}\` returned an error: ${JSON.stringify(error)}`);
78
79
  }
79
80
  }
80
81
  };
@@ -1,9 +1,10 @@
1
- import { a as assertSafeHeaderValue, b as assertSafeAddresses } from './address-fkXxLKza.mjs';
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { a as assertSafeHeaderValue, b as assertSafeAddresses } from './address-vSUAVU2T.mjs';
2
3
  import { isCaptureTransport } from './createCaptureTransport-Crz_8822.mjs';
3
- import { createCloudflareTransport } from './createCloudflareTransport-yHOVEsZv.mjs';
4
- import { toQueuedPayload } from './consumeQueuedSend-BEKOdaxU.mjs';
4
+ import { createCloudflareTransport } from './createCloudflareTransport-DXh1nUNi.mjs';
5
+ import { toQueuedPayload } from './consumeQueuedSend-B9hTDOZ6.mjs';
5
6
  import renderEmail from './renderEmail-hyS1bpVP.mjs';
6
- import createResendTransport from './createResendTransport-oNIorpzv.mjs';
7
+ import createResendTransport from './createResendTransport-CMQWjZoi.mjs';
7
8
 
8
9
  const buildDefaultTransport = (options) => {
9
10
  if (options.cloudflareSend) {
@@ -12,11 +13,14 @@ const buildDefaultTransport = (options) => {
12
13
  if (options.apiKey) {
13
14
  return createResendTransport(options.apiKey, options.from);
14
15
  }
15
- throw new Error("@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)");
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
+ );
16
20
  };
17
21
  const createMailer = (options) => {
18
22
  if (!options.from) {
19
- throw new Error("@lunora/mail: `from` is required");
23
+ throw new LunoraError("INTERNAL", "@lunora/mail: `from` is required");
20
24
  }
21
25
  const transport = options.transport ?? buildDefaultTransport(options);
22
26
  const buildPayload = async (options_) => {
@@ -65,7 +69,7 @@ const createMailer = (options) => {
65
69
  await transport.send(captured);
66
70
  return { queued: true };
67
71
  }
68
- throw new Error("@lunora/mail: `queue` binding is required for mailer.queue()");
72
+ throw new LunoraError("INTERNAL", "@lunora/mail: `queue` binding is required for mailer.queue()");
69
73
  }
70
74
  const payload = await buildPayload(options_);
71
75
  await options.queue.send(toQueuedPayload(payload));
@@ -1,5 +1,5 @@
1
1
  import { resendProvider } from '@visulima/email/providers/resend';
2
- import { r as requireRecipients, t as toProviderEmail, i as interpretSendResult } from './provider-transport-C5CVbjRF.mjs';
2
+ import { r as requireRecipients, t as toProviderEmail, i as interpretSendResult } from './provider-transport-C0xHi3oy.mjs';
3
3
 
4
4
  const createResendTransport = (apiKey, defaultFrom) => {
5
5
  const provider = resendProvider({ apiKey });
@@ -1,5 +1,5 @@
1
1
  import PostalMime from 'postal-mime';
2
- import { a as assertSafeHeaderValue } from './address-fkXxLKza.mjs';
2
+ import { a as assertSafeHeaderValue } from './address-vSUAVU2T.mjs';
3
3
 
4
4
  const safe = (label, value) => {
5
5
  if (value === void 0) {
@@ -1,4 +1,5 @@
1
- import { t as toAddressList, c as toAddress } from './address-fkXxLKza.mjs';
1
+ import { LunoraError } from '@lunora/errors';
2
+ import { t as toAddressList, c as toAddress } from './address-vSUAVU2T.mjs';
2
3
 
3
4
  const reasonOf = (rawError) => {
4
5
  if (rawError instanceof Error) {
@@ -19,7 +20,7 @@ const requireRecipients = (to) => {
19
20
  const list = toAddressList(to);
20
21
  const [first] = list ?? [];
21
22
  if (!list || first === void 0) {
22
- throw new Error("@lunora/mail: at least one recipient is required");
23
+ throw new LunoraError("INTERNAL", "@lunora/mail: at least one recipient is required");
23
24
  }
24
25
  return { first, list };
25
26
  };
@@ -39,7 +40,7 @@ const toProviderEmail = (payload, defaultFrom, to) => {
39
40
  const interpretSendResult = (result) => {
40
41
  if (!result.success || !result.data) {
41
42
  console.error(`@lunora/mail: send failed: ${reasonOf(result.error)}`);
42
- throw new Error("@lunora/mail: send failed");
43
+ throw new LunoraError("INTERNAL", "@lunora/mail: send failed");
43
44
  }
44
45
  return { id: result.data.messageId };
45
46
  };
package/dist/testing.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { LunoraError } from '@lunora/errors';
2
+
1
3
  const GET_CAPTURED_MAIL_OP = "__lunora_admin__:getCapturedMail";
2
4
  const DEFAULT_RPC_PATH = "/_lunora/rpc";
3
5
  const TRAILING_SLASH = /\/$/;
@@ -14,7 +16,7 @@ const listCapturedMail = async (options) => {
14
16
  method: "POST"
15
17
  });
16
18
  if (!response.ok) {
17
- throw new Error(`@lunora/mail/testing: getCapturedMail failed (HTTP ${String(response.status)})`);
19
+ throw new LunoraError("INTERNAL", `@lunora/mail/testing: getCapturedMail failed (HTTP ${String(response.status)})`);
18
20
  }
19
21
  const body = await response.json();
20
22
  return body.result?.entries ?? [];
@@ -32,7 +34,8 @@ const waitForMail = async (options) => {
32
34
  return match;
33
35
  }
34
36
  if (Date.now() >= deadline) {
35
- throw new Error(
37
+ throw new LunoraError(
38
+ "INTERNAL",
36
39
  `@lunora/mail/testing: no mail to "${options.to}"${options.subjectMatch === void 0 ? "" : ` matching "${options.subjectMatch}"`} within ${String(timeoutMs)}ms`
37
40
  );
38
41
  }
@@ -51,7 +54,10 @@ const extractLink = (mail, options = {}) => {
51
54
  return link;
52
55
  }
53
56
  }
54
- throw new Error(`@lunora/mail/testing: no link${options.match === void 0 ? "" : ` containing "${options.match}"`} found in the captured message`);
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
+ );
55
61
  };
56
62
 
57
63
  export { extractLink, listCapturedMail, waitForMail };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/mail",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "Email for Lunora: Resend adapter, TSX templates, and queue-backed sends",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -54,6 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
+ "@lunora/errors": "1.0.0-alpha.1",
57
58
  "@react-email/render": "2.0.9",
58
59
  "@visulima/email": "1.0.0-alpha.41",
59
60
  "postal-mime": "2.7.5"