@lunora/mail 1.0.0-alpha.2 → 1.0.0-alpha.21

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.
Files changed (43) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/inbound/index.d.mts +80 -69
  3. package/dist/inbound/index.d.ts +80 -69
  4. package/dist/inbound/index.mjs +1 -2
  5. package/dist/index.d.mts +95 -78
  6. package/dist/index.d.ts +95 -78
  7. package/dist/index.mjs +1 -7
  8. package/dist/packem_shared/address-Y4Z--6ZO.mjs +3 -0
  9. package/dist/packem_shared/capture-transport.d-BJ__iWzS.d.mts +117 -0
  10. package/dist/packem_shared/capture-transport.d-BJ__iWzS.d.ts +117 -0
  11. package/dist/packem_shared/consumeQueuedSend-BXrPhND5.mjs +1 -0
  12. package/dist/packem_shared/createCaptureSink-fVjCTUBC.mjs +1 -0
  13. package/dist/packem_shared/createCaptureTransport-BPdMkU0O.mjs +1 -0
  14. package/dist/packem_shared/createCloudflareTransport-V23CVw7F.mjs +1 -0
  15. package/dist/packem_shared/createInboundEmailHandler-B7H4h_sK.mjs +1 -0
  16. package/dist/packem_shared/createMailer-CsVLrc75.mjs +1 -0
  17. package/dist/packem_shared/createResendTransport-CpSJ4j7C.mjs +1 -0
  18. package/dist/packem_shared/parseInboundEmail-tJMs3YON.mjs +1 -0
  19. package/dist/packem_shared/provider-transport-BZngzyeS.mjs +1 -0
  20. package/dist/packem_shared/renderEmail-DM8_pWKa.mjs +1 -0
  21. package/dist/packem_shared/shard-CcKsuWAm.mjs +1 -0
  22. package/dist/packem_shared/shard.d-DVADjmEJ.d.mts +29 -0
  23. package/dist/packem_shared/shard.d-DVADjmEJ.d.ts +29 -0
  24. package/dist/testing.d.mts +9 -9
  25. package/dist/testing.d.ts +9 -9
  26. package/dist/testing.mjs +1 -57
  27. package/package.json +6 -5
  28. package/dist/packem_shared/address-fkXxLKza.mjs +0 -62
  29. package/dist/packem_shared/capture-transport.d-ChnhdPO2.d.mts +0 -117
  30. package/dist/packem_shared/capture-transport.d-ChnhdPO2.d.ts +0 -117
  31. package/dist/packem_shared/consumeQueuedSend-BEKOdaxU.mjs +0 -75
  32. package/dist/packem_shared/createCaptureSink-DeihS4LH.mjs +0 -63
  33. package/dist/packem_shared/createCaptureTransport-Crz_8822.mjs +0 -11
  34. package/dist/packem_shared/createCloudflareTransport-yHOVEsZv.mjs +0 -26
  35. package/dist/packem_shared/createInboundEmailHandler-D0uCOrU-.mjs +0 -83
  36. package/dist/packem_shared/createMailer-oEKPAd4J.mjs +0 -77
  37. package/dist/packem_shared/createResendTransport-oNIorpzv.mjs +0 -16
  38. package/dist/packem_shared/parseInboundEmail-Bw9u_1oc.mjs +0 -72
  39. package/dist/packem_shared/provider-transport-C5CVbjRF.mjs +0 -47
  40. package/dist/packem_shared/renderEmail-hyS1bpVP.mjs +0 -8
  41. package/dist/packem_shared/shard-CJ-TvmfT.mjs +0 -13
  42. package/dist/packem_shared/shard.d-CL2Lmliv.d.mts +0 -39
  43. package/dist/packem_shared/shard.d-CL2Lmliv.d.ts +0 -39
@@ -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 @@
1
+ import{LunoraError as c}from"@lunora/errors";const h=t=>({bcc:t.bcc,cc:t.cc,from:t.from,headers:t.headers,html:t.html,replyTo:t.replyTo,subject:t.subject,text:t.text,to:t.to}),b=async(t,s)=>{if(!s||typeof s!="object"||Array.isArray(s))throw new c("INTERNAL","@lunora/mail: queue message body must be an object");const e=s;if(typeof e.subject!="string")throw new TypeError("@lunora/mail: queue message must have a string `subject`");const m=typeof e.to=="string",y=Array.isArray(e.to)&&e.to.every(o=>typeof o=="string");if(!m&&!y)throw new c("INTERNAL","@lunora/mail: queue message `to` must be a string or string[]");const a=(o,r)=>{if(r!==void 0){if(typeof r!="string")throw new TypeError(`@lunora/mail: queue message \`${o}\` must be a string`);return r}},u=(o,r)=>{if(r!==void 0){if(typeof r=="string")return[r];if(Array.isArray(r)&&r.every(n=>typeof n=="string"))return r;throw new TypeError(`@lunora/mail: queue message \`${o}\` must be a string or string[]`)}};let i;if(e.headers!==void 0){if(!e.headers||typeof e.headers!="object"||Array.isArray(e.headers))throw new TypeError("@lunora/mail: queue message `headers` must be an object of string values");const o=Object.entries(e.headers);for(const[r,n]of o)if(typeof n!="string")throw new TypeError(`@lunora/mail: queue message header "${r}" must be a string`);i=e.headers}const f={bcc:u("bcc",e.bcc),cc:u("cc",e.cc),from:a("from",e.from),headers:i,html:a("html",e.html),replyTo:a("replyTo",e.replyTo),subject:e.subject,text:a("text",e.text),to:e.to};return t.send(f)};export{b as consumeQueuedSend,h as toQueuedPayload};
@@ -0,0 +1 @@
1
+ import{LunoraError as u}from"@lunora/errors";import{createCaptureTransport as s}from"./createCaptureTransport-BPdMkU0O.mjs";import n from"./createMailer-CsVLrc75.mjs";import{d as l,u as c}from"./shard-CcKsuWAm.mjs";const f="__lunora_admin__:recordMail",p=/^(?:dev(?:elopment)?|local(?:host)?|test)$/iu,m=["CF_ENV","ENVIRONMENT","NODE_ENV","WORKER_ENV"],E=(e,t)=>{const r=e[t];if(typeof r!="string"||r==="")throw new u("INTERNAL",`@lunora/mail: missing env var \`${t}\` — set it in .dev.vars (and \`wrangler secret put ${t}\` for secrets).`);return r},_=e=>{const t=e.LUNORA_MAIL_CAPTURE;if(typeof t=="string"){const r=t.toLowerCase();if(r==="1"||r==="true")return!0;if(r==="0"||r==="false")return!1;console.warn(`@lunora/mail: unrecognized LUNORA_MAIL_CAPTURE value "${t}" — expected "1"/"true" or "0"/"false"; falling back to environment detection.`)}return m.some(r=>{const o=e[r];return typeof o=="string"&&p.test(o)})},N=(e,t=l,r)=>({record:async o=>{const a=e.SHARD,i=typeof e.LUNORA_ADMIN_TOKEN=="string"?e.LUNORA_ADMIN_TOKEN:void 0;if(a===void 0||i===void 0)return{id:"uncaptured"};try{return{id:(await c(a,{adminToken:i,envelope:{args:o,functionPath:f},jurisdiction:r,label:"@lunora/mail: recording captured mail",shardKey:t})).result?.id??"captured"}}catch(d){return console.error("@lunora/mail: failed to record captured mail into the studio inbox —",d),{id:"uncaptured"}}}}),I=(e,t={})=>{const r=E(e,"MAIL_FROM");if(_(e))return n({from:r,transport:s(N(e,t.rootShard,t.jurisdiction))});if(t.cloudflareSend)return n({cloudflareSend:t.cloudflareSend,from:r});const o=typeof e.RESEND_API_KEY=="string"?e.RESEND_API_KEY:void 0;if(o!==void 0&&o!=="")return n({apiKey:o,from:r});throw new u("INTERNAL","@lunora/mail: no transport configured — provide `cloudflareSend` (a SEND_EMAIL binding) or RESEND_API_KEY, or run in a dev environment to capture.")};export{N as createCaptureSink,I as createMailerFromEnv,_ as shouldCaptureMail};
@@ -0,0 +1 @@
1
+ const a=Symbol.for("@lunora/mail.captureTransport"),t=r=>r[a]===!0,o=r=>({[a]:!0,send:async e=>r.record(e)});export{a as CAPTURE_TRANSPORT_BRAND,o as createCaptureTransport,t as isCaptureTransport};
@@ -0,0 +1 @@
1
+ import{LunoraError as t}from"@lunora/errors";import{cloudflareEmailProvider as l}from"@visulima/email/providers/cloudflare-email";import{o as d,c as f,f as u}from"./provider-transport-BZngzyeS.mjs";const g=o=>{const n=l({send:o.send});return{send:async e=>{await n.initialize();const i=e.cc!==void 0&&e.cc.length>0,a=e.bcc!==void 0&&e.bcc.length>0;if(i||a)throw new t("INTERNAL","@lunora/mail: Cloudflare Email Workers does not support cc/bcc — fan out one send per recipient instead");const{first:s,list:r}=d(e.to);if(r.length>1)throw new t("INTERNAL",`@lunora/mail: Cloudflare Email Workers is single-recipient but received ${String(r.length)} \`to\` addresses — fan out one send per recipient instead`);const c=await n.sendEmail(f(e,o.from,s));return u(c)}}};export{g as createCloudflareTransport};
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";import{u as d,d as u}from"./shard-CcKsuWAm.mjs";const h="message could not be processed",m=(t,a)=>{console.error("@lunora/mail/inbound: dropping message —",t),a.message.setReject(h)},y=t=>{const a=t.onError??m;return async(e,n,s)=>{const r={ctx:s,env:n,message:e};try{const o=await t.parse(e.raw);if(t.verify&&await t.verify(o,r)===!1)throw new c("INTERNAL","@lunora/mail/inbound: sender verification rejected the message");await t.dispatch(o,r)}catch(o){await a(o,r)}}},i=32768,l=t=>{let a="";for(let e=0;e<t.length;e+=i)a+=String.fromCharCode(...t.subarray(e,e+i));return btoa(a)},f=t=>t.attachments.length===0?t:{...t,attachments:t.attachments.map(a=>{const{content:e}=a;if(typeof e=="string")return a;const n=e instanceof Uint8Array?e:new Uint8Array(e);return{...a,content:l(n),encoding:"base64"}})},N=t=>{const a=t.shardKey??u,e=t.resolveArgs??(n=>f(n));return async(n,s)=>{const r=t.adminToken??(typeof s.env.LUNORA_ADMIN_TOKEN=="string"?s.env.LUNORA_ADMIN_TOKEN:void 0);if(r===void 0||r==="")throw new c("INTERNAL","@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");const o={args:e(n,s),functionPath:t.functionPath,shardKey:a};await d(t.shard,{adminToken:r,envelope:o,jurisdiction:t.jurisdiction,label:`@lunora/mail/inbound: dispatch to \`${t.functionPath}\``,shardKey:a})}};export{y as createInboundEmailHandler,N as dispatchToLunoraFunction};
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";import{m as s,f}from"./address-Y4Z--6ZO.mjs";import{isCaptureTransport as d}from"./createCaptureTransport-BPdMkU0O.mjs";import{createCloudflareTransport as l}from"./createCloudflareTransport-V23CVw7F.mjs";import{toQueuedPayload as p}from"./consumeQueuedSend-BXrPhND5.mjs";import h from"./renderEmail-DM8_pWKa.mjs";import w from"./createResendTransport-CpSJ4j7C.mjs";const y=e=>{if(e.cloudflareSend)return l({from:e.from,send:e.cloudflareSend});if(e.apiKey)return w(e.apiKey,e.from);throw new c("INTERNAL","@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)")},L=e=>{if(!e.from)throw new c("INTERNAL","@lunora/mail: `from` is required");const u=e.transport??y(e),n=async r=>{let{html:t}=r,{text:a}=r;if(r.react){const o=await h(r.react);t=t??o.html,a=a??o.text}if(s("subject",r.subject),r.headers)for(const[o,m]of Object.entries(r.headers))s(`header name "${o}"`,o),s(`header "${o}" value`,m);const i=r.from??e.from;return f({bcc:r.bcc,cc:r.cc,from:i,replyTo:r.replyTo,to:r.to}),{bcc:r.bcc,cc:r.cc,from:i,headers:r.headers,html:t,replyTo:r.replyTo,subject:r.subject,text:a,to:r.to}};return{queue:async r=>{if(!e.queue){if(d(u)){const a=await n(r);return await u.send(a),{queued:!0}}throw new c("INTERNAL","@lunora/mail: `queue` binding is required for mailer.queue()")}const t=await n(r);return await e.queue.send(p(t)),{queued:!0}},send:async r=>{const t=await n(r);return u.send(t)}}};export{L as default};
@@ -0,0 +1 @@
1
+ import{resendProvider as a}from"@visulima/email/providers/resend";import{o as m,c,f as d}from"./provider-transport-BZngzyeS.mjs";const p=(o,e)=>{const t=a({apiKey:o});return{send:async r=>{await t.initialize();const{first:n,list:i}=m(r.to),s=await t.sendEmail(c(r,e,i.length===1?n:i));return d(s)}}};export{p as default};
@@ -0,0 +1 @@
1
+ import l from"postal-mime";import{m as i}from"./address-Y4Z--6ZO.mjs";const s=(n,e)=>{if(e!==void 0)return i(`inbound ${n}`,e),e},m=n=>n.address!==void 0&&n.address!==""?n.name?`${n.name} <${n.address}>`:n.address:n.group?n.group.map(e=>e.address??"").filter(e=>e!=="").join(", "):n.name??"",a=(n,e)=>new RegExp(String.raw`\b${e}=([a-zA-Z]+)`,"i").exec(n)?.[1]?.toLowerCase()??null,p=n=>n===void 0||n===""?{dkim:null,dmarc:null,spf:null}:{dkim:a(n,"dkim"),dmarc:a(n,"dmarc"),spf:a(n,"spf")},g=async n=>{const e=await l.parse(n),d={};for(const o of e.headers)i(`inbound header \`${o.key}\``,o.value),d[o.key]=o.value;const c=e.headers.find(o=>o.key==="authentication-results")?.value,u=(e.to??[]).map(o=>{const r=m(o);return i("inbound to",r),r}),t=e.from?m(e.from):"";return i("inbound from",t),{attachments:e.attachments.map(o=>({content:o.content,disposition:o.disposition,...o.encoding===void 0?{}:{encoding:o.encoding},filename:o.filename,mimeType:o.mimeType})),authentication:p(c),from:t,headers:d,...e.html===void 0?{}:{html:e.html},...s("inReplyTo",e.inReplyTo)===void 0?{}:{inReplyTo:e.inReplyTo},...s("messageId",e.messageId)===void 0?{}:{messageId:e.messageId},...s("references",e.references)===void 0?{}:{references:e.references},...s("subject",e.subject)===void 0?{}:{subject:e.subject},...e.text===void 0?{}:{text:e.text},to:u}};export{g as parseInboundEmail};
@@ -0,0 +1 @@
1
+ import{LunoraError as s}from"@lunora/errors";import{i,o as n}from"./address-Y4Z--6ZO.mjs";const e=r=>r instanceof Error?r.message:r==null?"send failed":typeof r=="string"?r:typeof r=="number"||typeof r=="boolean"||typeof r=="bigint"?r.toString():JSON.stringify(r)??"send failed",c=r=>{const o=i(r),[t]=o??[];if(!o||t===void 0)throw new s("INTERNAL","@lunora/mail: at least one recipient is required");return{first:t,list:o}},f=(r,o,t)=>({bcc:i(r.bcc),cc:i(r.cc),from:n(r.from??o),headers:r.headers,html:r.html,replyTo:r.replyTo?n(r.replyTo):void 0,subject:r.subject,text:r.text,to:t}),d=r=>{if(!r.success||!r.data)throw console.error(`@lunora/mail: send failed: ${e(r.error)}`),new s("INTERNAL","@lunora/mail: send failed");return{id:r.data.messageId}};export{f as c,d as f,c as o};
@@ -0,0 +1 @@
1
+ import{render as e}from"@react-email/render";const n=async t=>{const[r,a]=await Promise.all([e(t,{pretty:!1}),e(t,{plainText:!0})]);return{html:r,text:a}};export{n as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as a}from"@lunora/errors";const c="__root__",s=(o,r)=>{if(r===void 0)return o;if(typeof o.jurisdiction!="function")throw new TypeError(`@lunora/mail: Durable Object namespace does not support jurisdiction("${r}") — update @cloudflare/workers-types or remove the jurisdiction option`);return o.jurisdiction(r)},d=async(o,r)=>{const i=s(o,r.jurisdiction),n=await i.get(i.idFromName(r.shardKey)).fetch("https://shard.internal/rpc",{body:JSON.stringify(r.envelope),headers:{authorization:`Bearer ${r.adminToken}`,"content-type":"application/json"},method:"POST"});if(n.ok===!1)throw new a("INTERNAL",`${r.label} failed (HTTP ${String(n.status??"?")}).`);const t=await n.json();if(typeof t=="object"&&t!==null&&"error"in t){const{error:e}=t;if(e!=null)throw new a("INTERNAL",`${r.label} returned an error: ${JSON.stringify(e)}`)}return t};export{c as d,d as u};
@@ -0,0 +1,29 @@
1
+ /** Structural projection of one shard stub — only `fetch` returning a Fetch-`Response`-like object. */
2
+ interface ShardStubLike {
3
+ fetch: (input: string, init?: {
4
+ body?: string;
5
+ headers?: Record<string, string>;
6
+ method?: string;
7
+ }) => Promise<{
8
+ json: () => Promise<unknown>;
9
+ ok?: boolean;
10
+ status?: number;
11
+ }>;
12
+ }
13
+ /**
14
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
15
+ * Cloudflare adds values over time.
16
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
17
+ */
18
+ type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
19
+ /** Structural projection of the `SHARD` Durable Object namespace. */
20
+ interface ShardNamespaceLike {
21
+ get: (id: unknown) => ShardStubLike;
22
+ idFromName: (name: string) => unknown;
23
+ /**
24
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
25
+ * workers-types releases (and test doubles) may not expose it.
26
+ */
27
+ jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ShardNamespaceLike;
28
+ }
29
+ export { DurableObjectJurisdiction as D, ShardNamespaceLike as S, ShardStubLike as a };
@@ -0,0 +1,29 @@
1
+ /** Structural projection of one shard stub — only `fetch` returning a Fetch-`Response`-like object. */
2
+ interface ShardStubLike {
3
+ fetch: (input: string, init?: {
4
+ body?: string;
5
+ headers?: Record<string, string>;
6
+ method?: string;
7
+ }) => Promise<{
8
+ json: () => Promise<unknown>;
9
+ ok?: boolean;
10
+ status?: number;
11
+ }>;
12
+ }
13
+ /**
14
+ * Cloudflare Durable Object data-residency jurisdiction. Widening union —
15
+ * Cloudflare adds values over time.
16
+ * @see https://developers.cloudflare.com/durable-objects/reference/data-location/
17
+ */
18
+ type DurableObjectJurisdiction = "eu" | "fedramp" | "us";
19
+ /** Structural projection of the `SHARD` Durable Object namespace. */
20
+ interface ShardNamespaceLike {
21
+ get: (id: unknown) => ShardStubLike;
22
+ idFromName: (name: string) => unknown;
23
+ /**
24
+ * Derive a jurisdiction-restricted subnamespace. Optional because older
25
+ * workers-types releases (and test doubles) may not expose it.
26
+ */
27
+ jurisdiction?: (jurisdiction: DurableObjectJurisdiction) => ShardNamespaceLike;
28
+ }
29
+ export { DurableObjectJurisdiction as D, ShardNamespaceLike as S, ShardStubLike as a };
@@ -1,4 +1,4 @@
1
- import { C as CapturedMail } from "./packem_shared/capture-transport.d-ChnhdPO2.mjs";
1
+ import { C as CapturedMail } from "./packem_shared/capture-transport.d-BJ__iWzS.mjs";
2
2
  import 'react';
3
3
  /** Minimal `fetch` projection so a test can inject a stub. */
4
4
  type FetchLike = (input: string, init?: {
@@ -33,16 +33,16 @@ interface WaitForMailOptions extends InboxOptions {
33
33
  /** Read the captured-mail inbox (newest first). */
34
34
  declare const listCapturedMail: (options: InboxOptions) => Promise<CapturedMail[]>;
35
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
- */
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
40
  declare const waitForMail: (options: WaitForMailOptions) => Promise<CapturedMail>;
41
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
- */
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
46
  declare const extractLink: (mail: CapturedMail, options?: {
47
47
  match?: string;
48
48
  }) => string;
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as CapturedMail } from "./packem_shared/capture-transport.d-ChnhdPO2.js";
1
+ import { C as CapturedMail } from "./packem_shared/capture-transport.d-BJ__iWzS.js";
2
2
  import 'react';
3
3
  /** Minimal `fetch` projection so a test can inject a stub. */
4
4
  type FetchLike = (input: string, init?: {
@@ -33,16 +33,16 @@ interface WaitForMailOptions extends InboxOptions {
33
33
  /** Read the captured-mail inbox (newest first). */
34
34
  declare const listCapturedMail: (options: InboxOptions) => Promise<CapturedMail[]>;
35
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
- */
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
40
  declare const waitForMail: (options: WaitForMailOptions) => Promise<CapturedMail>;
41
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
- */
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
46
  declare const extractLink: (mail: CapturedMail, options?: {
47
47
  match?: string;
48
48
  }) => string;
package/dist/testing.mjs CHANGED
@@ -1,57 +1 @@
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 };
1
+ import{LunoraError as e}from"@lunora/errors";const s="__lunora_admin__:getCapturedMail",c="/_lunora/rpc",l=/\/$/,u=async t=>new Promise(i=>{setTimeout(i,t)}),h=t=>Array.isArray(t.to)?t.to:[t.to],m=async t=>{const i=t.fetch??globalThis.fetch,n=`${t.baseUrl.replace(l,"")}${c}`,a=await i(n,{body:JSON.stringify({args:{limit:t.limit??50},functionPath:s}),headers:{authorization:`Bearer ${t.adminToken}`,"content-type":"application/json"},method:"POST"});if(!a.ok)throw new e("INTERNAL",`@lunora/mail/testing: getCapturedMail failed (HTTP ${String(a.status)})`);return(await a.json()).result?.entries??[]},w=async t=>{const i=t.timeoutMs??1e4,n=t.pollMs??250,a=Date.now()+i;for(;;){const o=(await m(t)).find(r=>h(r).includes(t.to)&&(t.subjectMatch===void 0||r.subject.includes(t.subjectMatch)));if(o)return o;if(Date.now()>=a)throw new e("INTERNAL",`@lunora/mail/testing: no mail to "${t.to}"${t.subjectMatch===void 0?"":` matching "${t.subjectMatch}"`} within ${String(i)}ms`);await u(n)}},d=/https?:\/\/[^\s"'<>)]+/g,f=/&(?:amp|#0*38|#x0*26);/giu,g=t=>t.replaceAll(f,"&"),M=(t,i={})=>{for(const n of[t.html,t.text]){if(n===void 0)continue;const a=(n.match(d)??[]).find(o=>i.match===void 0||o.includes(i.match));if(a!==void 0)return g(a)}throw new e("INTERNAL",`@lunora/mail/testing: no link${i.match===void 0?"":` containing "${i.match}"`} found in the captured message`)};export{M as extractLink,m as listCapturedMail,w as waitForMail};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/mail",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.21",
4
4
  "description": "Email for Lunora: Resend adapter, TSX templates, and queue-backed sends",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/mail"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "__assets__",
30
30
  "README.md",
31
31
  "LICENSE.md"
@@ -54,9 +54,10 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
- "@react-email/render": "2.0.9",
58
- "@visulima/email": "1.0.0-alpha.41",
59
- "postal-mime": "2.7.4"
57
+ "@lunora/errors": "1.0.0-alpha.8",
58
+ "@react-email/render": "2.1.0",
59
+ "@visulima/email": "2.1.2",
60
+ "postal-mime": "2.7.5"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "react": "^19.2.7"
@@ -1,62 +0,0 @@
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 };
@@ -1,117 +0,0 @@
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 };