@lunora/mail 1.0.0-alpha.60 → 1.0.0-alpha.61

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.
@@ -15,6 +15,18 @@ interface InboundAttachment {
15
15
  /** Declared MIME type (e.g. `image/png`). */
16
16
  mimeType: string;
17
17
  }
18
+ /** One reported `method=result [ptype.property=value]` clause of an `Authentication-Results` header. */
19
+ interface InboundAuthResult {
20
+ /**
21
+ * The identifier the result is about, lowercased — DKIM's signing domain
22
+ * (`header.d=`), SPF's envelope `MAIL FROM` domain (`smtp.mailfrom=`, local
23
+ * part dropped) or DMARC's `header.from=`. `null` when the clause reported
24
+ * none, in which case it cannot be aligned and vouches for nothing.
25
+ */
26
+ domain: string | null;
27
+ /** The result token, lowercased (`"pass"`, `"fail"`, `"none"`, …). */
28
+ result: string;
29
+ }
18
30
  /**
19
31
  * Sender-authentication verdicts pulled from the `Authentication-Results` header
20
32
  * the receiving MX (e.g. Cloudflare Email Routing) stamped on the message.
@@ -24,17 +36,27 @@ interface InboundAttachment {
24
36
  * spoofable, so a downstream handler MUST NOT make trust/authorization decisions
25
37
  * on `email.from` alone — gate on these verdicts (or your own policy) instead.
26
38
  * Verdicts are best-effort: when the receiving MX did not stamp an
27
- * `Authentication-Results` header, every field is `null` ("unknown").
39
+ * `Authentication-Results` header, every list is empty ("unknown").
40
+ *
41
+ * Each method holds a LIST, because RFC 8601 lets one header report the same
42
+ * method more than once and real mail does. An ESP-relayed message carries two
43
+ * DKIM signatures — the relay's and the author domain's — and the MX stamps a
44
+ * clause per signature in whatever order it verified them. Keeping only the
45
+ * first threw the aligned one away whenever it was not the one that happened to
46
+ * come first, and the message was rejected as unauthenticated. A consumer
47
+ * therefore asks "does ANY reported clause pass and align?", not "did the first
48
+ * one?".
28
49
  *
29
50
  * SECURITY: a bare `"pass"` is NOT proof the `From` header is genuine. SPF
30
51
  * authenticates the envelope `MAIL FROM` domain and DKIM the signing domain
31
52
  * (`d=`), and an attacker controls both — `spf=pass smtp.mailfrom=evil.example;
32
53
  * dkim=pass header.d=evil.example` is routine for a message whose `From` says
33
54
  * `ceo@victim.example`. Each verdict therefore carries the identifier it is
34
- * about (`*Domain`): an SPF or DKIM pass only vouches for `from` when that
35
- * domain equals the `From` address's domain (RFC 7489 strict alignment). Only a
36
- * DMARC pass already checked alignment for you. A pass with no identifier
37
- * reported cannot be aligned and must be treated as unauthenticated.
55
+ * about ({@link InboundAuthResult.domain}): an SPF or DKIM pass only vouches for
56
+ * `from` when that domain equals the `From` address's domain (RFC 7489 strict
57
+ * alignment). Only a DMARC pass already checked alignment for you. A pass with
58
+ * no identifier reported cannot be aligned and must be treated as
59
+ * unauthenticated.
38
60
  *
39
61
  * SECURITY: verdicts are read from the **first/topmost** `Authentication-Results`
40
62
  * header in document order. The receiving MX prepends its own genuine header per
@@ -45,18 +67,12 @@ interface InboundAttachment {
45
67
  * config this runtime-agnostic parser does not carry, so it is left to the host.
46
68
  */
47
69
  interface InboundAuthentication {
48
- /** DKIM verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
49
- dkim: string | null;
50
- /** Signing domain the DKIM verdict is about (`header.d=`, lowercased), or `null` when not reported. */
51
- dkimDomain: string | null;
52
- /** DMARC verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
53
- dmarc: string | null;
54
- /** `From` domain the DMARC verdict evaluated (`header.from=`, lowercased), or `null` when not reported. */
55
- dmarcDomain: string | null;
56
- /** SPF verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
57
- spf: string | null;
58
- /** Envelope `MAIL FROM` domain the SPF verdict is about (`smtp.mailfrom=`, local part dropped, lowercased), or `null` when not reported. */
59
- spfDomain: string | null;
70
+ /** Every DKIM clause the header reported, in header order; empty when the method was not reported. */
71
+ dkim: InboundAuthResult[];
72
+ /** Every DMARC clause the header reported, in header order; empty when the method was not reported. */
73
+ dmarc: InboundAuthResult[];
74
+ /** Every SPF clause the header reported, in header order; empty when the method was not reported. */
75
+ spf: InboundAuthResult[];
60
76
  }
61
77
  /** Normalised, transport-agnostic view of a received message. */
62
78
  interface InboundEmail {
@@ -67,8 +83,8 @@ interface InboundEmail {
67
83
  * about, parsed from the receiving MX's **first/topmost**
68
84
  * `Authentication-Results` header. SECURITY: see
69
85
  * {@link InboundAuthentication} — `from` is spoofable, and an SPF/DKIM pass
70
- * vouches for it only when its `*Domain` equals the `From` domain (a DMARC
71
- * pass checked that already). Reading the raw `headers["authentication-results"]`
86
+ * vouches for it only when that clause's `domain` equals the `From` domain (a
87
+ * DMARC pass checked that already). Reading the raw `headers["authentication-results"]`
72
88
  * map instead exposes last-wins (a lower, potentially attacker-injected)
73
89
  * value — trust `authentication`, not the raw map.
74
90
  */
@@ -98,6 +114,37 @@ interface InboundEmail {
98
114
  * `Uint8Array`, or a decoded string.
99
115
  */
100
116
  declare const parseInboundEmail: (raw: RawInboundEmail) => Promise<InboundEmail>;
117
+ /**
118
+ * THE inbound sender-authentication gate: does the receiving MX vouch for this
119
+ * message's `From` domain? Pass it as the `verify` hook of
120
+ * `createInboundEmailHandler` (or call it from your own) — it is one exported
121
+ * helper precisely so the insecure variants cannot be hand-rolled again.
122
+ *
123
+ * True when ANY reported DMARC, SPF or DKIM clause both **passes** and names a
124
+ * `domain` equal to the `From` address's domain. False otherwise — including for
125
+ * an empty verdict list (the MX stamped no `Authentication-Results` header at
126
+ * all, which is "unknown", not "fine") and for a `From` with no single mailbox
127
+ * to align against.
128
+ *
129
+ * SECURITY — the two halves are each load-bearing.
130
+ *
131
+ * **Alignment.** A bare `pass` proves nothing about `From`. SPF authenticates the
132
+ * envelope `MAIL FROM` domain and DKIM the signing `d=`, both attacker-chosen, so
133
+ * `spf=pass`+`dkim=pass` for `evil.example` is routine on a message whose `From`
134
+ * says `ceo@victim.example`. Only a clause whose own `domain` equals the `From`
135
+ * domain vouches for it. Alignment is STRICT (RFC 7489): there is no
136
+ * public-suffix list here, so `mail.example.com` does not vouch for
137
+ * `example.com`. A pass reporting no domain (`null`) cannot be aligned and is
138
+ * rejected. A DMARC pass already checked alignment at the MX.
139
+ *
140
+ * **Every clause, not the first.** One header legitimately reports a method more
141
+ * than once (an ESP-relayed message is DKIM-signed by both the relay and the
142
+ * author domain), and the aligned clause is not reliably the first, so reading
143
+ * only the first bounced fully authenticated mail. "Any clause passes AND aligns"
144
+ * stays strictly narrower than a bare pass: a clause vouching for some other
145
+ * domain contributes nothing.
146
+ */
147
+ declare const authenticatesFrom: (email: InboundEmail) => boolean;
101
148
  /**
102
149
  * Structural projection of Cloudflare's `ForwardableEmailMessage` (verified
103
150
  * against `@cloudflare/workers-types`' `ForwardableEmailMessage`). Only the
@@ -301,10 +348,11 @@ interface DispatchToLunoraFunctionOptions<TEnv = Record<string, unknown>> {
301
348
  * and then hands the message to `retain` if one is configured, bouncing only
302
349
  * when there is nowhere durable to put it (see {@link createInboundEmailHandler}).
303
350
  *
304
- * SECURITY: the RPC carries the admin bearer, so the target function runs with
305
- * RLS bypassed over fully attacker-controlled, spoofable input see the module
306
- * docstring. Verify the sender (`verify` hook / `email.authentication`) before
307
- * making any trust decision in the target function.
351
+ * SECURITY: the RPC is marked a trusted system dispatch, so `functionPath` may
352
+ * (and should) name an `internalMutation`/`internalAction`a public `mutation`
353
+ * target is callable by any browser client with a forged message. The input is
354
+ * fully attacker-controlled and spoofable; verify the sender (`verify` hook /
355
+ * `email.authentication`) before making any trust decision in the target.
308
356
  */
309
357
  declare const dispatchToLunoraFunction: <TEnv extends Record<string, unknown> = Record<string, unknown>>(options: DispatchToLunoraFunctionOptions<TEnv>) => InboundDispatch<TEnv>;
310
358
  export {
@@ -339,7 +387,7 @@ type DispatchToLunoraFunctionOptions,
339
387
  * });
340
388
  * ```
341
389
  */
342
- type ForwardableEmailMessageLike, type InboundAttachment, type InboundAuthentication,
390
+ type ForwardableEmailMessageLike, type InboundAttachment, type InboundAuthResult, type InboundAuthentication,
343
391
  /**
344
392
  * `@lunora/mail/inbound` — inbound Email Routing support.
345
393
  *
@@ -435,4 +483,4 @@ type InboundVerify, type RawInboundEmail,
435
483
  * });
436
484
  * ```
437
485
  */
438
- type RpcEnvelope, type ShardNamespaceLike, createInboundEmailHandler, dispatchToLunoraFunction, parseInboundEmail };
486
+ type RpcEnvelope, type ShardNamespaceLike, authenticatesFrom, createInboundEmailHandler, dispatchToLunoraFunction, parseInboundEmail };
@@ -15,6 +15,18 @@ interface InboundAttachment {
15
15
  /** Declared MIME type (e.g. `image/png`). */
16
16
  mimeType: string;
17
17
  }
18
+ /** One reported `method=result [ptype.property=value]` clause of an `Authentication-Results` header. */
19
+ interface InboundAuthResult {
20
+ /**
21
+ * The identifier the result is about, lowercased — DKIM's signing domain
22
+ * (`header.d=`), SPF's envelope `MAIL FROM` domain (`smtp.mailfrom=`, local
23
+ * part dropped) or DMARC's `header.from=`. `null` when the clause reported
24
+ * none, in which case it cannot be aligned and vouches for nothing.
25
+ */
26
+ domain: string | null;
27
+ /** The result token, lowercased (`"pass"`, `"fail"`, `"none"`, …). */
28
+ result: string;
29
+ }
18
30
  /**
19
31
  * Sender-authentication verdicts pulled from the `Authentication-Results` header
20
32
  * the receiving MX (e.g. Cloudflare Email Routing) stamped on the message.
@@ -24,17 +36,27 @@ interface InboundAttachment {
24
36
  * spoofable, so a downstream handler MUST NOT make trust/authorization decisions
25
37
  * on `email.from` alone — gate on these verdicts (or your own policy) instead.
26
38
  * Verdicts are best-effort: when the receiving MX did not stamp an
27
- * `Authentication-Results` header, every field is `null` ("unknown").
39
+ * `Authentication-Results` header, every list is empty ("unknown").
40
+ *
41
+ * Each method holds a LIST, because RFC 8601 lets one header report the same
42
+ * method more than once and real mail does. An ESP-relayed message carries two
43
+ * DKIM signatures — the relay's and the author domain's — and the MX stamps a
44
+ * clause per signature in whatever order it verified them. Keeping only the
45
+ * first threw the aligned one away whenever it was not the one that happened to
46
+ * come first, and the message was rejected as unauthenticated. A consumer
47
+ * therefore asks "does ANY reported clause pass and align?", not "did the first
48
+ * one?".
28
49
  *
29
50
  * SECURITY: a bare `"pass"` is NOT proof the `From` header is genuine. SPF
30
51
  * authenticates the envelope `MAIL FROM` domain and DKIM the signing domain
31
52
  * (`d=`), and an attacker controls both — `spf=pass smtp.mailfrom=evil.example;
32
53
  * dkim=pass header.d=evil.example` is routine for a message whose `From` says
33
54
  * `ceo@victim.example`. Each verdict therefore carries the identifier it is
34
- * about (`*Domain`): an SPF or DKIM pass only vouches for `from` when that
35
- * domain equals the `From` address's domain (RFC 7489 strict alignment). Only a
36
- * DMARC pass already checked alignment for you. A pass with no identifier
37
- * reported cannot be aligned and must be treated as unauthenticated.
55
+ * about ({@link InboundAuthResult.domain}): an SPF or DKIM pass only vouches for
56
+ * `from` when that domain equals the `From` address's domain (RFC 7489 strict
57
+ * alignment). Only a DMARC pass already checked alignment for you. A pass with
58
+ * no identifier reported cannot be aligned and must be treated as
59
+ * unauthenticated.
38
60
  *
39
61
  * SECURITY: verdicts are read from the **first/topmost** `Authentication-Results`
40
62
  * header in document order. The receiving MX prepends its own genuine header per
@@ -45,18 +67,12 @@ interface InboundAttachment {
45
67
  * config this runtime-agnostic parser does not carry, so it is left to the host.
46
68
  */
47
69
  interface InboundAuthentication {
48
- /** DKIM verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
49
- dkim: string | null;
50
- /** Signing domain the DKIM verdict is about (`header.d=`, lowercased), or `null` when not reported. */
51
- dkimDomain: string | null;
52
- /** DMARC verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
53
- dmarc: string | null;
54
- /** `From` domain the DMARC verdict evaluated (`header.from=`, lowercased), or `null` when not reported. */
55
- dmarcDomain: string | null;
56
- /** SPF verdict (`"pass"`/`"fail"`/…), or `null` when not reported. */
57
- spf: string | null;
58
- /** Envelope `MAIL FROM` domain the SPF verdict is about (`smtp.mailfrom=`, local part dropped, lowercased), or `null` when not reported. */
59
- spfDomain: string | null;
70
+ /** Every DKIM clause the header reported, in header order; empty when the method was not reported. */
71
+ dkim: InboundAuthResult[];
72
+ /** Every DMARC clause the header reported, in header order; empty when the method was not reported. */
73
+ dmarc: InboundAuthResult[];
74
+ /** Every SPF clause the header reported, in header order; empty when the method was not reported. */
75
+ spf: InboundAuthResult[];
60
76
  }
61
77
  /** Normalised, transport-agnostic view of a received message. */
62
78
  interface InboundEmail {
@@ -67,8 +83,8 @@ interface InboundEmail {
67
83
  * about, parsed from the receiving MX's **first/topmost**
68
84
  * `Authentication-Results` header. SECURITY: see
69
85
  * {@link InboundAuthentication} — `from` is spoofable, and an SPF/DKIM pass
70
- * vouches for it only when its `*Domain` equals the `From` domain (a DMARC
71
- * pass checked that already). Reading the raw `headers["authentication-results"]`
86
+ * vouches for it only when that clause's `domain` equals the `From` domain (a
87
+ * DMARC pass checked that already). Reading the raw `headers["authentication-results"]`
72
88
  * map instead exposes last-wins (a lower, potentially attacker-injected)
73
89
  * value — trust `authentication`, not the raw map.
74
90
  */
@@ -98,6 +114,37 @@ interface InboundEmail {
98
114
  * `Uint8Array`, or a decoded string.
99
115
  */
100
116
  declare const parseInboundEmail: (raw: RawInboundEmail) => Promise<InboundEmail>;
117
+ /**
118
+ * THE inbound sender-authentication gate: does the receiving MX vouch for this
119
+ * message's `From` domain? Pass it as the `verify` hook of
120
+ * `createInboundEmailHandler` (or call it from your own) — it is one exported
121
+ * helper precisely so the insecure variants cannot be hand-rolled again.
122
+ *
123
+ * True when ANY reported DMARC, SPF or DKIM clause both **passes** and names a
124
+ * `domain` equal to the `From` address's domain. False otherwise — including for
125
+ * an empty verdict list (the MX stamped no `Authentication-Results` header at
126
+ * all, which is "unknown", not "fine") and for a `From` with no single mailbox
127
+ * to align against.
128
+ *
129
+ * SECURITY — the two halves are each load-bearing.
130
+ *
131
+ * **Alignment.** A bare `pass` proves nothing about `From`. SPF authenticates the
132
+ * envelope `MAIL FROM` domain and DKIM the signing `d=`, both attacker-chosen, so
133
+ * `spf=pass`+`dkim=pass` for `evil.example` is routine on a message whose `From`
134
+ * says `ceo@victim.example`. Only a clause whose own `domain` equals the `From`
135
+ * domain vouches for it. Alignment is STRICT (RFC 7489): there is no
136
+ * public-suffix list here, so `mail.example.com` does not vouch for
137
+ * `example.com`. A pass reporting no domain (`null`) cannot be aligned and is
138
+ * rejected. A DMARC pass already checked alignment at the MX.
139
+ *
140
+ * **Every clause, not the first.** One header legitimately reports a method more
141
+ * than once (an ESP-relayed message is DKIM-signed by both the relay and the
142
+ * author domain), and the aligned clause is not reliably the first, so reading
143
+ * only the first bounced fully authenticated mail. "Any clause passes AND aligns"
144
+ * stays strictly narrower than a bare pass: a clause vouching for some other
145
+ * domain contributes nothing.
146
+ */
147
+ declare const authenticatesFrom: (email: InboundEmail) => boolean;
101
148
  /**
102
149
  * Structural projection of Cloudflare's `ForwardableEmailMessage` (verified
103
150
  * against `@cloudflare/workers-types`' `ForwardableEmailMessage`). Only the
@@ -301,10 +348,11 @@ interface DispatchToLunoraFunctionOptions<TEnv = Record<string, unknown>> {
301
348
  * and then hands the message to `retain` if one is configured, bouncing only
302
349
  * when there is nowhere durable to put it (see {@link createInboundEmailHandler}).
303
350
  *
304
- * SECURITY: the RPC carries the admin bearer, so the target function runs with
305
- * RLS bypassed over fully attacker-controlled, spoofable input see the module
306
- * docstring. Verify the sender (`verify` hook / `email.authentication`) before
307
- * making any trust decision in the target function.
351
+ * SECURITY: the RPC is marked a trusted system dispatch, so `functionPath` may
352
+ * (and should) name an `internalMutation`/`internalAction`a public `mutation`
353
+ * target is callable by any browser client with a forged message. The input is
354
+ * fully attacker-controlled and spoofable; verify the sender (`verify` hook /
355
+ * `email.authentication`) before making any trust decision in the target.
308
356
  */
309
357
  declare const dispatchToLunoraFunction: <TEnv extends Record<string, unknown> = Record<string, unknown>>(options: DispatchToLunoraFunctionOptions<TEnv>) => InboundDispatch<TEnv>;
310
358
  export {
@@ -339,7 +387,7 @@ type DispatchToLunoraFunctionOptions,
339
387
  * });
340
388
  * ```
341
389
  */
342
- type ForwardableEmailMessageLike, type InboundAttachment, type InboundAuthentication,
390
+ type ForwardableEmailMessageLike, type InboundAttachment, type InboundAuthResult, type InboundAuthentication,
343
391
  /**
344
392
  * `@lunora/mail/inbound` — inbound Email Routing support.
345
393
  *
@@ -435,4 +483,4 @@ type InboundVerify, type RawInboundEmail,
435
483
  * });
436
484
  * ```
437
485
  */
438
- type RpcEnvelope, type ShardNamespaceLike, createInboundEmailHandler, dispatchToLunoraFunction, parseInboundEmail };
486
+ type RpcEnvelope, type ShardNamespaceLike, authenticatesFrom, createInboundEmailHandler, dispatchToLunoraFunction, parseInboundEmail };
@@ -1 +1 @@
1
- import{createInboundEmailHandler as r,dispatchToLunoraFunction as a}from"../packem_shared/createInboundEmailHandler-C6V47mz5.mjs";import{parseInboundEmail as t}from"../packem_shared/parseInboundEmail-BG0NU_e9.mjs";export{r as createInboundEmailHandler,a as dispatchToLunoraFunction,t as parseInboundEmail};
1
+ import{createInboundEmailHandler as n,dispatchToLunoraFunction as r}from"../packem_shared/createInboundEmailHandler-vXSNL5mZ.mjs";import{authenticatesFrom as t,parseInboundEmail as i}from"../packem_shared/authenticatesFrom-xpKAFMRN.mjs";export{t as authenticatesFrom,n as createInboundEmailHandler,r as dispatchToLunoraFunction,i as parseInboundEmail};
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{createCaptureTransport as a}from"./packem_shared/createCaptureTransport-CKc6NpZR.mjs";import{createCloudflareTransport as t}from"./packem_shared/createCloudflareTransport-DpfCxn16.mjs";import{default as u}from"./packem_shared/createMailer-BJs2hnV-.mjs";import{createCaptureSink as f,createMailerFromEnv as l,shouldCaptureMail as m}from"./packem_shared/createCaptureSink-4T2LJOYC.mjs";import{consumeQueuedSend as s,toQueuedPayload as c}from"./packem_shared/consumeQueuedSend-CRfALnCU.mjs";import{default as i}from"./packem_shared/renderEmail-BOUnr6i3.mjs";import{default as M}from"./packem_shared/createResendTransport-DDkOitck.mjs";export{s as consumeQueuedSend,f as createCaptureSink,a as createCaptureTransport,t as createCloudflareTransport,u as createMailer,l as createMailerFromEnv,M as createResendTransport,i as renderEmail,m as shouldCaptureMail,c as toQueuedPayload};
1
+ import{createCaptureTransport as a}from"./packem_shared/createCaptureTransport-CKc6NpZR.mjs";import{createCloudflareTransport as t}from"./packem_shared/createCloudflareTransport-DpfCxn16.mjs";import{default as u}from"./packem_shared/createMailer-CjQmT7Ln.mjs";import{createCaptureSink as f,createMailerFromEnv as l,shouldCaptureMail as m}from"./packem_shared/createCaptureSink-wbDNGuA-.mjs";import{consumeQueuedSend as s,toQueuedPayload as c}from"./packem_shared/consumeQueuedSend-CRfALnCU.mjs";import{default as i}from"./packem_shared/renderEmail-BOUnr6i3.mjs";import{default as M}from"./packem_shared/createResendTransport-DDkOitck.mjs";export{s as consumeQueuedSend,f as createCaptureSink,a as createCaptureTransport,t as createCloudflareTransport,u as createMailer,l as createMailerFromEnv,M as createResendTransport,i as renderEmail,m as shouldCaptureMail,c as toQueuedPayload};
@@ -0,0 +1 @@
1
+ import p from"postal-mime";import{a as c}from"./address-soPj2Z0j.mjs";const d=(s,e)=>{if(e!==void 0)return c(`inbound ${s}`,e),e},u=s=>s.address!==void 0&&s.address!==""?s.name?`${s.name} <${s.address}>`:s.address:s.group?s.group.map(e=>e.address??"").filter(e=>e!=="").join(", "):s.name??"",l=/"(?:[^"\\]|\\.)*(?:"|$)|\((?:[^()\\]|\\.)*(?:\)|$)/g,g=s=>s.replaceAll(l,e=>e.startsWith('"')?e.replaceAll(/[;=]/gu," "):" "),m=(s,e,r)=>{const i=g(s).matchAll(new RegExp(String.raw`(?:^|;)\s*${e}\s*(?:/\s*\d+\s*)?=\s*([a-z]+)([^;]*)`,"gi")),a=new RegExp(String.raw`\b${r.split(".").join(String.raw`\s*\.\s*`)}\s*=\s*"?([^\s;"]+)`,"i");return[...i].map(t=>{const n=a.exec(t[2]??"")?.[1];return{domain:n===void 0?null:n.slice(n.lastIndexOf("@")+1).toLowerCase(),result:(t[1]??"").toLowerCase()}})},v=s=>s===void 0||s===""?{dkim:[],dmarc:[],spf:[]}:{dkim:m(s,"dkim","header.d"),dmarc:m(s,"dmarc","header.from"),spf:m(s,"spf","smtp.mailfrom")},I=async s=>{const e=await p.parse(s),r={};for(const o of e.headers)c(`inbound header \`${o.key}\``,o.value),r[o.key]=o.value;const i=e.headers.find(o=>o.key==="authentication-results")?.value,a=(e.to??[]).map(o=>{const f=u(o);return c("inbound to",f),f}),t=e.from?u(e.from):"";return c("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:v(i),from:t,headers:r,...e.html===void 0?{}:{html:e.html},...d("inReplyTo",e.inReplyTo)===void 0?{}:{inReplyTo:e.inReplyTo},...d("messageId",e.messageId)===void 0?{}:{messageId:e.messageId},...d("references",e.references)===void 0?{}:{references:e.references},...d("subject",e.subject)===void 0?{}:{subject:e.subject},...e.text===void 0?{}:{text:e.text},to:a}},h=/<([^<>]*)>$/,x=s=>{const e=h.exec(s)?.[1]??s,r=e.indexOf("@");if(!(r===-1||r!==e.lastIndexOf("@")))return e.slice(r+1).trim().toLowerCase()},$=s=>{const e=x(s.from);if(e===void 0)return!1;const r=n=>n.some(o=>o.result==="pass"&&o.domain===e),{dkim:i,dmarc:a,spf:t}=s.authentication;return r(a)||r(t)||r(i)};export{$ as authenticatesFrom,I as parseInboundEmail};
@@ -1 +1 @@
1
- import{createCaptureTransport as d}from"./createCaptureTransport-CKc6NpZR.mjs";import"./createCloudflareTransport-DpfCxn16.mjs";import n from"./createMailer-BJs2hnV-.mjs";import{LunoraError as c}from"@lunora/errors";import{D as s,p as l}from"./shard-C0aRRoVy.mjs";import"./consumeQueuedSend-CRfALnCU.mjs";import"./renderEmail-BOUnr6i3.mjs";import"./createResendTransport-DDkOitck.mjs";const E="__lunora_admin__:recordMail",p=/^(?:dev(?:elopment)?|local(?:host)?|test)$/iu,f=["CF_ENV","ENVIRONMENT","NODE_ENV","WORKER_ENV"],_=(t,e)=>{const r=t[e];if(typeof r!="string"||r==="")throw new c("INTERNAL",`@lunora/mail: missing env var \`${e}\` — set it in .dev.vars (and \`wrangler secret put ${e}\` for secrets).`);return r},N=t=>{const e=t.LUNORA_MAIL_CAPTURE;if(typeof e=="string"){const r=e.toLowerCase();if(r==="1"||r==="true")return!0;if(r==="0"||r==="false")return!1;console.warn(`@lunora/mail: unrecognized LUNORA_MAIL_CAPTURE value "${e}" — expected "1"/"true" or "0"/"false"; falling back to environment detection.`)}return f.some(r=>{const o=t[r];return typeof o=="string"&&p.test(o)})},m=(t,e=s,r)=>({record:async o=>{const i=t.SHARD,a=typeof t.LUNORA_ADMIN_TOKEN=="string"?t.LUNORA_ADMIN_TOKEN:void 0;if(i===void 0||a===void 0)return{id:"uncaptured"};try{return{id:(await l(i,{adminToken:a,envelope:{args:o,functionPath:E},jurisdiction:r,label:"@lunora/mail: recording captured mail",shardKey:e})).result?.id??"captured"}}catch(u){return console.error("@lunora/mail: failed to record captured mail into the studio inbox —",u),{id:"uncaptured"}}}}),L=(t,e={})=>{const r=_(t,"MAIL_FROM");if(N(t))return n({from:r,transport:d(m(t,e.rootShard,e.jurisdiction))});if(e.cloudflareSend)return n({cloudflareSend:e.cloudflareSend,from:r});const o=typeof t.RESEND_API_KEY=="string"?t.RESEND_API_KEY:void 0;if(o!==void 0&&o!=="")return n({apiKey:o,from:r});throw new c("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{m as createCaptureSink,L as createMailerFromEnv,N as shouldCaptureMail};
1
+ import{createCaptureTransport as d}from"./createCaptureTransport-CKc6NpZR.mjs";import"./createCloudflareTransport-DpfCxn16.mjs";import n from"./createMailer-CjQmT7Ln.mjs";import{LunoraError as c}from"@lunora/errors";import{D as s,p as l}from"./shard-CRhAKK93.mjs";import"./consumeQueuedSend-CRfALnCU.mjs";import"./renderEmail-BOUnr6i3.mjs";import"./createResendTransport-DDkOitck.mjs";const E="__lunora_admin__:recordMail",p=/^(?:dev(?:elopment)?|local(?:host)?|test)$/iu,f=["CF_ENV","ENVIRONMENT","NODE_ENV","WORKER_ENV"],_=(t,e)=>{const r=t[e];if(typeof r!="string"||r==="")throw new c("INTERNAL",`@lunora/mail: missing env var \`${e}\` — set it in .dev.vars (and \`wrangler secret put ${e}\` for secrets).`);return r},N=t=>{const e=t.LUNORA_MAIL_CAPTURE;if(typeof e=="string"){const r=e.toLowerCase();if(r==="1"||r==="true")return!0;if(r==="0"||r==="false")return!1;console.warn(`@lunora/mail: unrecognized LUNORA_MAIL_CAPTURE value "${e}" — expected "1"/"true" or "0"/"false"; falling back to environment detection.`)}return f.some(r=>{const o=t[r];return typeof o=="string"&&p.test(o)})},m=(t,e=s,r)=>({record:async o=>{const i=t.SHARD,a=typeof t.LUNORA_ADMIN_TOKEN=="string"?t.LUNORA_ADMIN_TOKEN:void 0;if(i===void 0||a===void 0)return{id:"uncaptured"};try{return{id:(await l(i,{adminToken:a,envelope:{args:o,functionPath:E},jurisdiction:r,label:"@lunora/mail: recording captured mail",shardKey:e})).result?.id??"captured"}}catch(u){return console.error("@lunora/mail: failed to record captured mail into the studio inbox —",u),{id:"uncaptured"}}}}),L=(t,e={})=>{const r=_(t,"MAIL_FROM");if(N(t))return n({from:r,transport:d(m(t,e.rootShard,e.jurisdiction))});if(e.cloudflareSend)return n({cloudflareSend:e.cloudflareSend,from:r});const o=typeof t.RESEND_API_KEY=="string"?t.RESEND_API_KEY:void 0;if(o!==void 0&&o!=="")return n({apiKey:o,from:r});throw new c("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{m as createCaptureSink,L as createMailerFromEnv,N as shouldCaptureMail};
@@ -1 +1 @@
1
- import{LunoraError as d}from"@lunora/errors";import{D as h,p as l}from"./shard-C0aRRoVy.mjs";const f=r=>{let n="";for(let e=0;e<r.length;e+=32768)n+=String.fromCharCode(...r.subarray(e,e+32768));return btoa(n)},E="message could not be processed",u=(r,n)=>{console.error("@lunora/mail/inbound: dropping message —",r),n.message.setReject(E)},y=r=>{const n=r.onError??u;return async(t,e,c)=>{const a={ctx:c,env:e,message:t};let o;try{if(o=await r.parse(t.raw),r.verify&&await r.verify(o,a)===!1)throw new d("INTERNAL","@lunora/mail/inbound: sender verification rejected the message")}catch(i){await n(i,a);return}try{await r.dispatch(o,a)}catch(i){if(r.onError)try{await r.onError(i,a)}catch(s){console.error("@lunora/mail/inbound: onError threw while reporting a dispatch failure —",s)}if(r.retain)try{await r.retain(o,a,i);return}catch(s){console.error("@lunora/mail/inbound: retain failed to take ownership of the message —",s)}u(i,a)}}},m=r=>r.attachments.length===0?r:{...r,attachments:r.attachments.map(n=>{const{content:t}=n;if(typeof t=="string")return n;const e=t instanceof Uint8Array?t:new Uint8Array(t);return{...n,content:f(e),encoding:"base64"}})},A=r=>{const n=r.shardKey??h,t=r.resolveArgs??(e=>m(e));return async(e,c)=>{const a=r.adminToken??(typeof c.env.LUNORA_ADMIN_TOKEN=="string"?c.env.LUNORA_ADMIN_TOKEN:void 0);if(a===void 0||a==="")throw new d("INTERNAL","@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");const o={args:t(e,c),functionPath:r.functionPath,shardKey:n};await l(r.shard,{adminToken:a,envelope:o,jurisdiction:r.jurisdiction,label:`@lunora/mail/inbound: dispatch to \`${r.functionPath}\``,shardKey:n})}};export{y as createInboundEmailHandler,A as dispatchToLunoraFunction};
1
+ import{LunoraError as d}from"@lunora/errors";import{D as h,p as l}from"./shard-CRhAKK93.mjs";const f=r=>{let n="";for(let e=0;e<r.length;e+=32768)n+=String.fromCharCode(...r.subarray(e,e+32768));return btoa(n)},E="message could not be processed",u=(r,n)=>{console.error("@lunora/mail/inbound: dropping message —",r),n.message.setReject(E)},y=r=>{const n=r.onError??u;return async(t,e,c)=>{const a={ctx:c,env:e,message:t};let o;try{if(o=await r.parse(t.raw),r.verify&&await r.verify(o,a)===!1)throw new d("INTERNAL","@lunora/mail/inbound: sender verification rejected the message")}catch(i){await n(i,a);return}try{await r.dispatch(o,a)}catch(i){if(r.onError)try{await r.onError(i,a)}catch(s){console.error("@lunora/mail/inbound: onError threw while reporting a dispatch failure —",s)}if(r.retain)try{await r.retain(o,a,i);return}catch(s){console.error("@lunora/mail/inbound: retain failed to take ownership of the message —",s)}u(i,a)}}},m=r=>r.attachments.length===0?r:{...r,attachments:r.attachments.map(n=>{const{content:t}=n;if(typeof t=="string")return n;const e=t instanceof Uint8Array?t:new Uint8Array(t);return{...n,content:f(e),encoding:"base64"}})},A=r=>{const n=r.shardKey??h,t=r.resolveArgs??(e=>m(e));return async(e,c)=>{const a=r.adminToken??(typeof c.env.LUNORA_ADMIN_TOKEN=="string"?c.env.LUNORA_ADMIN_TOKEN:void 0);if(a===void 0||a==="")throw new d("INTERNAL","@lunora/mail/inbound: missing LUNORA_ADMIN_TOKEN — cannot authorize inbound dispatch to the shard RPC.");const o={args:t(e,c),functionPath:r.functionPath,shardKey:n};await l(r.shard,{adminToken:a,envelope:o,jurisdiction:r.jurisdiction,label:`@lunora/mail/inbound: dispatch to \`${r.functionPath}\``,shardKey:n})}};export{y as createInboundEmailHandler,A as dispatchToLunoraFunction};
@@ -0,0 +1 @@
1
+ import{LunoraError as f}from"@lunora/errors";import{a as o,c as m}from"./address-soPj2Z0j.mjs";import{isCaptureTransport as s}from"./createCaptureTransport-CKc6NpZR.mjs";import{createCloudflareTransport as y}from"./createCloudflareTransport-DpfCxn16.mjs";import{toQueuedPayload as i}from"./consumeQueuedSend-CRfALnCU.mjs";import b from"./renderEmail-BOUnr6i3.mjs";import w from"./createResendTransport-DDkOitck.mjs";const h=()=>{if(typeof crypto<"u"){if(typeof crypto.randomUUID=="function")return crypto.randomUUID();if(typeof crypto.getRandomValues=="function"){const r=crypto.getRandomValues(new Uint8Array(16));return Array.from(r,d=>d.toString(16).padStart(2,"0")).join("")}}throw new Error("randomSessionId: no Web Crypto available — a session id needs crypto.randomUUID or crypto.getRandomValues")},p=r=>{if(r.cloudflareSend)return y({from:r.from,send:r.cloudflareSend});if(r.apiKey)return w(r.apiKey,r.from);throw new f("INTERNAL","@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)")},N=r=>{if(!r.from)throw new f("INTERNAL","@lunora/mail: `from` is required");const d=r.transport??p(r),n=async e=>{let{html:a}=e,{text:u}=e;if(e.react){const t=await b(e.react);a=a??t.html,u=u??t.text}if(o("subject",e.subject),e.headers)for(const[t,l]of Object.entries(e.headers))o(`header name "${t}"`,t),o(`header "${t}" value`,l);const c=e.from??r.from;return m({bcc:e.bcc,cc:e.cc,from:c,replyTo:e.replyTo,to:e.to}),{bcc:e.bcc,cc:e.cc,from:c,headers:e.headers,html:a,replyTo:e.replyTo,subject:e.subject,text:u,to:e.to}};return{queue:async e=>{if(!r.queue){if(s(d)){const c=await n(e);return await d.send(c),{queued:!0}}throw new f("INTERNAL","@lunora/mail: `queue` binding is required for mailer.queue()")}const a=await n(e),u=e.idempotencyKey??h();return await r.queue.send(i({...a,idempotencyKey:u})),{queued:!0}},send:async e=>{const a=await n(e);return d.send(a)}}};export{N as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as s}from"@lunora/errors";const d="__root__",a=(t,r)=>{if(r===void 0)return t;if(typeof t.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 t.jurisdiction(r)},l=async(t,r)=>{const i=a(t,r.jurisdiction),e=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","x-lunora-system":"1"},method:"POST"});if(e.ok===!1)throw new s("INTERNAL",`${r.label} failed (HTTP ${String(e.status??"?")}).`);const o=await e.json();if(typeof o=="object"&&o!==null&&"error"in o){const{error:n}=o;if(n!=null)throw new s("INTERNAL",`${r.label} returned an error: ${JSON.stringify(n)}`)}return o};export{d as D,l as p};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/mail",
3
- "version": "1.0.0-alpha.60",
3
+ "version": "1.0.0-alpha.61",
4
4
  "description": "Email for Lunora: Resend adapter, TSX templates, and queue-backed sends",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -54,7 +54,7 @@
54
54
  "access": "public"
55
55
  },
56
56
  "dependencies": {
57
- "@lunora/errors": "1.0.0-alpha.29",
57
+ "@lunora/errors": "1.0.0-alpha.30",
58
58
  "@react-email/render": "2.1.0",
59
59
  "@visulima/email": "2.1.8",
60
60
  "postal-mime": "2.7.6"
@@ -1 +0,0 @@
1
- import{LunoraError as l}from"@lunora/errors";import{a as f,c as m}from"./address-soPj2Z0j.mjs";import{isCaptureTransport as s}from"./createCaptureTransport-CKc6NpZR.mjs";import{createCloudflareTransport as y}from"./createCloudflareTransport-DpfCxn16.mjs";import{toQueuedPayload as i}from"./consumeQueuedSend-CRfALnCU.mjs";import w from"./renderEmail-BOUnr6i3.mjs";import b from"./createResendTransport-DDkOitck.mjs";const h=()=>{if(typeof crypto<"u"){if(typeof crypto.randomUUID=="function")return crypto.randomUUID();if(typeof crypto.getRandomValues=="function"){const r=crypto.getRandomValues(new Uint8Array(16));return Array.from(r,u=>u.toString(16).padStart(2,"0")).join("")}}return Date.now().toString(36)},q=r=>{if(r.cloudflareSend)return y({from:r.from,send:r.cloudflareSend});if(r.apiKey)return b(r.apiKey,r.from);throw new l("INTERNAL","@lunora/mail: a transport is required — pass `transport`, `cloudflareSend` (Cloudflare Email Workers, the default), or `apiKey` (Resend)")},g=r=>{if(!r.from)throw new l("INTERNAL","@lunora/mail: `from` is required");const u=r.transport??q(r),n=async e=>{let{html:a}=e,{text:c}=e;if(e.react){const t=await w(e.react);a=a??t.html,c=c??t.text}if(f("subject",e.subject),e.headers)for(const[t,o]of Object.entries(e.headers))f(`header name "${t}"`,t),f(`header "${t}" value`,o);const d=e.from??r.from;return m({bcc:e.bcc,cc:e.cc,from:d,replyTo:e.replyTo,to:e.to}),{bcc:e.bcc,cc:e.cc,from:d,headers:e.headers,html:a,replyTo:e.replyTo,subject:e.subject,text:c,to:e.to}};return{queue:async e=>{if(!r.queue){if(s(u)){const d=await n(e);return await u.send(d),{queued:!0}}throw new l("INTERNAL","@lunora/mail: `queue` binding is required for mailer.queue()")}const a=await n(e),c=e.idempotencyKey??h();return await r.queue.send(i({...a,idempotencyKey:c})),{queued:!0}},send:async e=>{const a=await n(e);return u.send(a)}}};export{g as default};
@@ -1 +0,0 @@
1
- import f from"postal-mime";import{a as d}from"./address-soPj2Z0j.mjs";const t=(n,e)=>{if(e!==void 0)return d(`inbound ${n}`,e),e},u=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??"",m=(n,e,i)=>{const s=new RegExp(String.raw`(?:^|;)\s*${e}\s*=\s*([a-z]+)([^;]*)`,"i").exec(n.replaceAll(/\([^()]*\)/g,""));if(!s)return[null,null];const r=new RegExp(String.raw`\b${i.replaceAll(".",String.raw`\.`)}\s*=\s*"?([^\s;"]+)`,"i").exec(s[2]??"")?.[1];return[s[1]?.toLowerCase()??null,r===void 0?null:r.slice(r.lastIndexOf("@")+1).toLowerCase()]},p=n=>{if(n===void 0||n==="")return{dkim:null,dkimDomain:null,dmarc:null,dmarcDomain:null,spf:null,spfDomain:null};const[e,i]=m(n,"dkim","header.d"),[s,r]=m(n,"dmarc","header.from"),[a,c]=m(n,"spf","smtp.mailfrom");return{dkim:e,dkimDomain:i,dmarc:s,dmarcDomain:r,spf:a,spfDomain:c}},b=async n=>{const e=await f.parse(n),i={};for(const o of e.headers)d(`inbound header \`${o.key}\``,o.value),i[o.key]=o.value;const s=e.headers.find(o=>o.key==="authentication-results")?.value,r=(e.to??[]).map(o=>{const l=u(o);return d("inbound to",l),l}),a=e.from?u(e.from):"";return d("inbound from",a),{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(s),from:a,headers:i,...e.html===void 0?{}:{html:e.html},...t("inReplyTo",e.inReplyTo)===void 0?{}:{inReplyTo:e.inReplyTo},...t("messageId",e.messageId)===void 0?{}:{messageId:e.messageId},...t("references",e.references)===void 0?{}:{references:e.references},...t("subject",e.subject)===void 0?{}:{subject:e.subject},...e.text===void 0?{}:{text:e.text},to:r}};export{b as parseInboundEmail};
@@ -1 +0,0 @@
1
- import{LunoraError as a}from"@lunora/errors";const u="__root__",s=(t,r)=>{if(r===void 0)return t;if(typeof t.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 t.jurisdiction(r)},l=async(t,r)=>{const i=s(t,r.jurisdiction),e=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(e.ok===!1)throw new a("INTERNAL",`${r.label} failed (HTTP ${String(e.status??"?")}).`);const o=await e.json();if(typeof o=="object"&&o!==null&&"error"in o){const{error:n}=o;if(n!=null)throw new a("INTERNAL",`${r.label} returned an error: ${JSON.stringify(n)}`)}return o};export{u as D,l as p};