@pramen/server 0.0.58 → 0.0.59

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.
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET
15
15
  export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
16
16
  export type { ExpiringToken } from "./runtime/token";
17
17
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
18
- export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
18
+ export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
19
19
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
20
20
  export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
21
21
  export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET
26
26
  // token with the same JWKS cache (and its key-rotation handling) the Worker uses.
27
27
  export { HmacStrategy, JwksStrategy } from "./auth";
28
28
  // --- mail (ctx.mail) ---
29
- export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
29
+ export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
30
30
  // --- queue (ctx.queue — Cloudflare Queues) ---
31
31
  export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
32
32
  export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
@@ -47,6 +47,33 @@ export declare class CloudflareEmailAdapter implements MailAdapter {
47
47
  from: MailAddress;
48
48
  }): Promise<void>;
49
49
  }
50
+ /** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
51
+ *
52
+ * Worth the key for one reason: Cloudflare will only send from a domain that is a zone
53
+ * in the same account, and some accounts additionally refuse any recipient that is not a
54
+ * verified destination in Email Routing ("destination address is not a verified
55
+ * address"). That is workable for a handful of operators and hopeless for real users.
56
+ * Mailgun asks the domain be verified once, then delivers to anyone.
57
+ *
58
+ * A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
59
+ * throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
60
+ * status would turn a bounced sign-in link into silence. The response body rides along
61
+ * in the message because Mailgun's 400s are specific and worth reading ("not a valid
62
+ * address", "domain not found"); the key never does. */
63
+ export declare class MailgunAdapter implements MailAdapter {
64
+ private readonly apiKey;
65
+ private readonly domain;
66
+ /** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
67
+ * deployments and a key from one 401s against the other. */
68
+ private readonly apiBase;
69
+ constructor(apiKey: string, domain: string,
70
+ /** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
71
+ * deployments and a key from one 401s against the other. */
72
+ apiBase?: string);
73
+ send(message: MailMessage & {
74
+ from: MailAddress;
75
+ }): Promise<void>;
76
+ }
50
77
  /** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
51
78
  * (or a dashboard) can read the "inbox" instead of really sending. */
52
79
  export declare class KvMailAdapter implements MailAdapter {
@@ -73,9 +100,14 @@ export declare class UnconfiguredMailAdapter implements MailAdapter {
73
100
  send(): Promise<void>;
74
101
  }
75
102
  /** Build `ctx.mail` from the environment:
76
- * - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
103
+ * - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
104
+ * - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
77
105
  * - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
78
106
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
79
107
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
80
- * stash security emails in KV). */
108
+ * stash security emails in KV).
109
+ *
110
+ * Mailgun outranks the binding on purpose. The binding tends to be present because the
111
+ * infrastructure declares it, whereas an API key is only ever there because somebody put
112
+ * it there — so when both exist, the key is the newer decision. */
81
113
  export declare function createMail(env: EnvBag, kv?: Kv): Mail;
@@ -5,10 +5,15 @@
5
5
  //
6
6
  // await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
7
7
  //
8
- // On Cloudflare the transport is Cloudflare Email Sending (the `send_email`/`EMAIL`
9
- // binding, no API keys). With no verified sender configured (local/dev), mail is
10
- // captured instead of sent to KV (so an e2e/dashboard can read the "inbox") or
11
- // in-memory so handlers work unchanged off-platform.
8
+ // Two real transports. Cloudflare Email Sending (the `send_email`/`EMAIL` binding) needs
9
+ // no API keys, but it can only send FROM a domain that is a zone in the same account,
10
+ // and on some accounts only TO addresses verified in Email Routing which rules it out
11
+ // whenever the recipients are ordinary people. Mailgun is the way out of both: an HTTP
12
+ // API, any recipient, at the cost of a key. Configure it and it wins.
13
+ //
14
+ // With neither configured (local/dev), mail is captured instead of sent — to KV (so an
15
+ // e2e/dashboard can read the "inbox") or in-memory — so handlers work unchanged
16
+ // off-platform.
12
17
  /** The `ctx.mail` facade: resolves the sender, validates, and delegates to the adapter. */
13
18
  export class Mail {
14
19
  adapter;
@@ -49,6 +54,60 @@ export class CloudflareEmailAdapter {
49
54
  });
50
55
  }
51
56
  }
57
+ /** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
58
+ *
59
+ * Worth the key for one reason: Cloudflare will only send from a domain that is a zone
60
+ * in the same account, and some accounts additionally refuse any recipient that is not a
61
+ * verified destination in Email Routing ("destination address is not a verified
62
+ * address"). That is workable for a handful of operators and hopeless for real users.
63
+ * Mailgun asks the domain be verified once, then delivers to anyone.
64
+ *
65
+ * A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
66
+ * throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
67
+ * status would turn a bounced sign-in link into silence. The response body rides along
68
+ * in the message because Mailgun's 400s are specific and worth reading ("not a valid
69
+ * address", "domain not found"); the key never does. */
70
+ export class MailgunAdapter {
71
+ apiKey;
72
+ domain;
73
+ apiBase;
74
+ constructor(apiKey, domain,
75
+ /** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
76
+ * deployments and a key from one 401s against the other. */
77
+ apiBase = "https://api.mailgun.net") {
78
+ this.apiKey = apiKey;
79
+ this.domain = domain;
80
+ this.apiBase = apiBase;
81
+ }
82
+ async send(message) {
83
+ const body = new URLSearchParams();
84
+ body.set("from", message.from.name ? `${message.from.name} <${message.from.email}>` : message.from.email);
85
+ for (const to of Array.isArray(message.to) ? message.to : [message.to])
86
+ body.append("to", to);
87
+ body.set("subject", message.subject);
88
+ if (message.text)
89
+ body.set("text", message.text);
90
+ if (message.html)
91
+ body.set("html", message.html);
92
+ if (message.replyTo) {
93
+ const r = message.replyTo;
94
+ body.set("h:Reply-To", typeof r === "string" ? r : r.name ? `${r.name} <${r.email}>` : r.email);
95
+ }
96
+ const res = await fetch(`${this.apiBase.replace(/\/+$/, "")}/v3/${encodeURIComponent(this.domain)}/messages`, {
97
+ method: "POST",
98
+ headers: {
99
+ // `api` is the literal username Mailgun expects; the key is the password.
100
+ authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
101
+ "content-type": "application/x-www-form-urlencoded",
102
+ },
103
+ body,
104
+ });
105
+ if (!res.ok) {
106
+ const detail = await res.text().catch(() => "");
107
+ throw new Error(`mailgun: send failed (${res.status})${detail ? ` — ${detail.slice(0, 300)}` : ""}`);
108
+ }
109
+ }
110
+ }
52
111
  /** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
53
112
  * (or a dashboard) can read the "inbox" instead of really sending. */
54
113
  export class KvMailAdapter {
@@ -76,21 +135,35 @@ export class MemoryMailAdapter {
76
135
  * instead of delivering them. Mirrors how files fail closed without FILES_SECRET. */
77
136
  export class UnconfiguredMailAdapter {
78
137
  async send() {
79
- throw new Error("ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
80
- "or MAIL_CAPTURE=true to capture in dev.");
138
+ throw new Error("ctx.mail: no transport configured — set MAIL_FROM with either the EMAIL binding " +
139
+ "or MAILGUN_API_KEY + MAILGUN_DOMAIN to send, or MAIL_CAPTURE=true to capture in dev.");
81
140
  }
82
141
  }
83
142
  /** Build `ctx.mail` from the environment:
84
- * - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
143
+ * - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
144
+ * - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
85
145
  * - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
86
146
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
87
147
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
88
- * stash security emails in KV). */
148
+ * stash security emails in KV).
149
+ *
150
+ * Mailgun outranks the binding on purpose. The binding tends to be present because the
151
+ * infrastructure declares it, whereas an API key is only ever there because somebody put
152
+ * it there — so when both exist, the key is the newer decision. */
89
153
  export function createMail(env, kv) {
90
154
  const binding = env.EMAIL;
91
155
  const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
156
+ const str = (k) => typeof env[k] === "string" && env[k] ? env[k] : undefined;
157
+ const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
158
+ const mailgunKey = str("MAILGUN_API_KEY");
159
+ const mailgunDomain = str("MAILGUN_DOMAIN");
160
+ if (mailgunKey && mailgunDomain && fromAddr) {
161
+ return new Mail(new MailgunAdapter(mailgunKey, mailgunDomain, str("MAILGUN_API_BASE")), {
162
+ email: fromAddr,
163
+ name,
164
+ });
165
+ }
92
166
  if (binding && fromAddr) {
93
- const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
94
167
  return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
95
168
  }
96
169
  if (env.MAIL_CAPTURE === "true") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/index.ts CHANGED
@@ -95,7 +95,7 @@ export type { ExpiringToken } from "./runtime/token";
95
95
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
96
96
 
97
97
  // --- mail (ctx.mail) ---
98
- export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
98
+ export { Mail, CloudflareEmailAdapter, MailgunAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
99
99
  export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
100
100
 
101
101
  // --- queue (ctx.queue — Cloudflare Queues) ---
@@ -5,10 +5,15 @@
5
5
  //
6
6
  // await ctx.mail.send({ to: "u@x.com", subject: "Welcome", text: "…" });
7
7
  //
8
- // On Cloudflare the transport is Cloudflare Email Sending (the `send_email`/`EMAIL`
9
- // binding, no API keys). With no verified sender configured (local/dev), mail is
10
- // captured instead of sent to KV (so an e2e/dashboard can read the "inbox") or
11
- // in-memory so handlers work unchanged off-platform.
8
+ // Two real transports. Cloudflare Email Sending (the `send_email`/`EMAIL` binding) needs
9
+ // no API keys, but it can only send FROM a domain that is a zone in the same account,
10
+ // and on some accounts only TO addresses verified in Email Routing which rules it out
11
+ // whenever the recipients are ordinary people. Mailgun is the way out of both: an HTTP
12
+ // API, any recipient, at the cost of a key. Configure it and it wins.
13
+ //
14
+ // With neither configured (local/dev), mail is captured instead of sent — to KV (so an
15
+ // e2e/dashboard can read the "inbox") or in-memory — so handlers work unchanged
16
+ // off-platform.
12
17
 
13
18
  import type { Kv } from "./kv";
14
19
  import type { EnvBag } from "../sdk/handlers";
@@ -83,6 +88,56 @@ export class CloudflareEmailAdapter implements MailAdapter {
83
88
  }
84
89
  }
85
90
 
91
+ /** Mailgun — an HTTP transport, for when Cloudflare Email Sending cannot be used.
92
+ *
93
+ * Worth the key for one reason: Cloudflare will only send from a domain that is a zone
94
+ * in the same account, and some accounts additionally refuse any recipient that is not a
95
+ * verified destination in Email Routing ("destination address is not a verified
96
+ * address"). That is workable for a handful of operators and hopeless for real users.
97
+ * Mailgun asks the domain be verified once, then delivers to anyone.
98
+ *
99
+ * A non-2xx THROWS, deliberately: `ctx.mail.send` is normally called from a task, and a
100
+ * throw is what makes the outbox retry and then dead-letter visibly. Swallowing the
101
+ * status would turn a bounced sign-in link into silence. The response body rides along
102
+ * in the message because Mailgun's 400s are specific and worth reading ("not a valid
103
+ * address", "domain not found"); the key never does. */
104
+ export class MailgunAdapter implements MailAdapter {
105
+ constructor(
106
+ private readonly apiKey: string,
107
+ private readonly domain: string,
108
+ /** `https://api.eu.mailgun.net` for an EU-region account; the two are separate
109
+ * deployments and a key from one 401s against the other. */
110
+ private readonly apiBase: string = "https://api.mailgun.net",
111
+ ) {}
112
+
113
+ async send(message: MailMessage & { from: MailAddress }): Promise<void> {
114
+ const body = new URLSearchParams();
115
+ body.set("from", message.from.name ? `${message.from.name} <${message.from.email}>` : message.from.email);
116
+ for (const to of Array.isArray(message.to) ? message.to : [message.to]) body.append("to", to);
117
+ body.set("subject", message.subject);
118
+ if (message.text) body.set("text", message.text);
119
+ if (message.html) body.set("html", message.html);
120
+ if (message.replyTo) {
121
+ const r = message.replyTo;
122
+ body.set("h:Reply-To", typeof r === "string" ? r : r.name ? `${r.name} <${r.email}>` : r.email);
123
+ }
124
+
125
+ const res = await fetch(`${this.apiBase.replace(/\/+$/, "")}/v3/${encodeURIComponent(this.domain)}/messages`, {
126
+ method: "POST",
127
+ headers: {
128
+ // `api` is the literal username Mailgun expects; the key is the password.
129
+ authorization: `Basic ${btoa(`api:${this.apiKey}`)}`,
130
+ "content-type": "application/x-www-form-urlencoded",
131
+ },
132
+ body,
133
+ });
134
+ if (!res.ok) {
135
+ const detail = await res.text().catch(() => "");
136
+ throw new Error(`mailgun: send failed (${res.status})${detail ? ` — ${detail.slice(0, 300)}` : ""}`);
137
+ }
138
+ }
139
+ }
140
+
86
141
  /** Dev/test transport: stash the message in KV under `mail:<recipient>` so an e2e suite
87
142
  * (or a dashboard) can read the "inbox" instead of really sending. */
88
143
  export class KvMailAdapter implements MailAdapter {
@@ -109,23 +164,39 @@ export class MemoryMailAdapter implements MailAdapter {
109
164
  export class UnconfiguredMailAdapter implements MailAdapter {
110
165
  async send(): Promise<void> {
111
166
  throw new Error(
112
- "ctx.mail: no transport configured — set MAIL_FROM (with the EMAIL binding) to send, " +
113
- "or MAIL_CAPTURE=true to capture in dev.",
167
+ "ctx.mail: no transport configured — set MAIL_FROM with either the EMAIL binding " +
168
+ "or MAILGUN_API_KEY + MAILGUN_DOMAIN to send, or MAIL_CAPTURE=true to capture in dev.",
114
169
  );
115
170
  }
116
171
  }
117
172
 
118
173
  /** Build `ctx.mail` from the environment:
119
- * - `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
174
+ * - `MAILGUN_API_KEY` + `MAILGUN_DOMAIN` + `MAIL_FROM` → Mailgun (real send).
175
+ * - else `EMAIL` binding + `MAIL_FROM` → Cloudflare Email Sending (real send).
120
176
  * - else `MAIL_CAPTURE=true` → capture (KV inbox if a Kv is given, else in-memory) with
121
177
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
122
178
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
123
- * stash security emails in KV). */
179
+ * stash security emails in KV).
180
+ *
181
+ * Mailgun outranks the binding on purpose. The binding tends to be present because the
182
+ * infrastructure declares it, whereas an API key is only ever there because somebody put
183
+ * it there — so when both exist, the key is the newer decision. */
124
184
  export function createMail(env: EnvBag, kv?: Kv): Mail {
125
185
  const binding = env.EMAIL as SendEmailBinding | undefined;
126
186
  const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
187
+ const str = (k: string): string | undefined =>
188
+ typeof env[k] === "string" && (env[k] as string) ? (env[k] as string) : undefined;
189
+ const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
190
+
191
+ const mailgunKey = str("MAILGUN_API_KEY");
192
+ const mailgunDomain = str("MAILGUN_DOMAIN");
193
+ if (mailgunKey && mailgunDomain && fromAddr) {
194
+ return new Mail(new MailgunAdapter(mailgunKey, mailgunDomain, str("MAILGUN_API_BASE")), {
195
+ email: fromAddr,
196
+ name,
197
+ });
198
+ }
127
199
  if (binding && fromAddr) {
128
- const name = typeof env.MAIL_FROM_NAME === "string" ? env.MAIL_FROM_NAME : undefined;
129
200
  return new Mail(new CloudflareEmailAdapter(binding), { email: fromAddr, name });
130
201
  }
131
202
  if (env.MAIL_CAPTURE === "true") {