@freecorps/ember-email 0.5.6 → 0.5.7

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/README.md CHANGED
@@ -97,6 +97,10 @@ What it does:
97
97
  routing subdomain, so removal is a dashboard step.
98
98
  - **Mailboxes:** it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule
99
99
  that routes somewhere else.
100
+ - **Catch-all:** with `catchAll: true` (a mailbox of the domain is its catch-all, `upsertMailbox({ catchAll: true })`),
101
+ the zone's catch-all rule → your Worker, so every address no mailbox has reaches that mailbox, with the address it
102
+ was sent to in its To. Only for the zone apex, since the rule belongs to the zone. It never takes over a catch-all
103
+ that routes somewhere else. `destroyDomain`, or a sync without `catchAll`, turns it back off.
100
104
  - **Events:** it adds an `email.sending` event subscription → the component's queue.
101
105
 
102
106
  ## Offline replicas: `@freecorps/ember-email/schema`
package/dist/client.d.ts CHANGED
@@ -133,6 +133,7 @@ export interface EmailComponentApi {
133
133
  text: string;
134
134
  html?: string;
135
135
  } | null;
136
+ catchAll?: boolean | null;
136
137
  }, string>;
137
138
  removeMailbox: Ref<"mutation", {
138
139
  name: string;
@@ -143,6 +144,7 @@ export interface EmailComponentApi {
143
144
  address: string;
144
145
  displayName?: string;
145
146
  autoReply?: unknown;
147
+ catchAll?: boolean;
146
148
  }>>;
147
149
  route: Ref<"query", {
148
150
  to: string;
@@ -54,6 +54,15 @@ export declare function ensureWorkerRule(c: CfCreds, zoneId: string, address: st
54
54
  id: string;
55
55
  created: boolean;
56
56
  }>;
57
+ /**
58
+ * The zone's catch-all rule → this Worker (every address no literal rule takes). Left alone, with an error, when it
59
+ * is on and sends mail somewhere else; a disabled or "drop" catch-all is taken over.
60
+ */
61
+ export declare function ensureCatchAllRule(c: CfCreds, zoneId: string, worker: string): Promise<{
62
+ created: boolean;
63
+ }>;
64
+ /** Turns the zone's catch-all back off (drop), only when it points at this Worker. */
65
+ export declare function releaseCatchAllRule(c: CfCreds, zoneId: string, worker: string): Promise<boolean>;
57
66
  export declare function deleteRule(c: CfCreds, zoneId: string, id: string): Promise<void>;
58
67
  /** Queues event subscription: Email Service delivery events of `domain` → `queue` (by name). */
59
68
  export declare function ensureEventSubscription(c: CfCreds, zoneId: string, domain: string, queue: string): Promise<{
@@ -118,6 +118,28 @@ export async function ensureWorkerRule(c, zoneId, address, worker) {
118
118
  });
119
119
  return { id: r.id, created: true };
120
120
  }
121
+ const pointsAt = (r, worker) => r.actions.some((x) => x.type === "worker" && x.value?.includes(worker));
122
+ /**
123
+ * The zone's catch-all rule → this Worker (every address no literal rule takes). Left alone, with an error, when it
124
+ * is on and sends mail somewhere else; a disabled or "drop" catch-all is taken over.
125
+ */
126
+ export async function ensureCatchAllRule(c, zoneId, worker) {
127
+ const r = await call(c, "GET", `/zones/${zoneId}/email/routing/rules/catch_all`);
128
+ if (r.enabled && pointsAt(r, worker))
129
+ return { created: false };
130
+ if (r.enabled && !r.actions.every((x) => x.type === "drop"))
131
+ throw new CfError(`the zone's catch-all already routes mail elsewhere (${r.actions.map((x) => x.type).join(", ")}); not changing it`, 409, []);
132
+ await call(c, "PUT", `/zones/${zoneId}/email/routing/rules/catch_all`, { enabled: true, matchers: [{ type: "all" }], actions: [{ type: "worker", value: [worker] }] });
133
+ return { created: true };
134
+ }
135
+ /** Turns the zone's catch-all back off (drop), only when it points at this Worker. */
136
+ export async function releaseCatchAllRule(c, zoneId, worker) {
137
+ const r = await call(c, "GET", `/zones/${zoneId}/email/routing/rules/catch_all`);
138
+ if (!pointsAt(r, worker))
139
+ return false;
140
+ await call(c, "PUT", `/zones/${zoneId}/email/routing/rules/catch_all`, { enabled: false, matchers: [{ type: "all" }], actions: [{ type: "drop" }] });
141
+ return true;
142
+ }
121
143
  export async function deleteRule(c, zoneId, id) {
122
144
  await call(c, "DELETE", `/zones/${zoneId}/email/routing/rules/${id}`);
123
145
  }
@@ -1,4 +1,4 @@
1
- export { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureEventSubscription, ensureSending, ensureWorkerRule, findZone, routingState, sendingDomain, type CfCreds, type DnsRecord, type RoutingState, } from "./api.ts";
1
+ export { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureEventSubscription, ensureSending, ensureWorkerRule, ensureCatchAllRule, releaseCatchAllRule, findZone, routingState, sendingDomain, type CfCreds, type DnsRecord, type RoutingState, } from "./api.ts";
2
2
  export { destroyDomain, isDomainName, syncDomain, type DomainState, type SyncDomainDeps, type SyncDomainOptions } from "./onboard.ts";
3
3
  /**
4
4
  * Names `ember deploy` gives an app's Worker and the email component's delivery-events queue (`install` is the name
@@ -7,7 +7,7 @@
7
7
  // const { worker, eventsQueue } = emailResources("acme", "prod");
8
8
  // let d = await syncDomain(c, { name: "mail.acme.com" }, { prev: null, mailboxes: ["support@mail.acme.com"], worker, queue: eventsQueue });
9
9
  // // keep `d` (it holds the ids of what was created); later: await destroyDomain(c, d)
10
- export { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureEventSubscription, ensureSending, ensureWorkerRule, findZone, routingState, sendingDomain, } from "./api.js";
10
+ export { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureEventSubscription, ensureSending, ensureWorkerRule, ensureCatchAllRule, releaseCatchAllRule, findZone, routingState, sendingDomain, } from "./api.js";
11
11
  export { destroyDomain, isDomainName, syncDomain } from "./onboard.js";
12
12
  /**
13
13
  * Names `ember deploy` gives an app's Worker and the email component's delivery-events queue (`install` is the name
@@ -12,6 +12,8 @@ export type DomainState = {
12
12
  ruleIds: string[];
13
13
  subscriptionId?: string;
14
14
  createdSending?: boolean;
15
+ /** The zone's catch-all rule points at this Worker (set by `catchAll`; `destroyDomain` turns it off). */
16
+ catchAll?: string;
15
17
  notes: string[];
16
18
  };
17
19
  /** A syntactically valid (lowercase) domain name. */
@@ -35,6 +37,11 @@ export type SyncDomainDeps = {
35
37
  worker: string;
36
38
  /** The component's delivery-events queue (see `emailResources`). */
37
39
  queue: string;
40
+ /**
41
+ * A mailbox of this domain is its catch-all: the zone's catch-all rule → `worker`. Only for a zone apex (the rule
42
+ * is the zone's); refused when the catch-all already routes mail elsewhere.
43
+ */
44
+ catchAll?: boolean;
38
45
  };
39
46
  /**
40
47
  * Onboards (or re-checks) a domain. Idempotent: run it again until `status` is `verified` (a new sending domain
@@ -2,7 +2,7 @@
2
2
  // subdomain only when asked), one Worker rule per mailbox address, and the delivery-event subscription to the
3
3
  // component's queue. The caller keeps the returned `DomainState` (it holds the ids of what was created) and passes it
4
4
  // back on the next sync and to `destroyDomain`. Product-agnostic: no storage, no HTTP API, no API keys.
5
- import { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureEventSubscription, ensureSending, ensureWorkerRule, findZone, routingState } from "./api.js";
5
+ import { CfError, deleteEventSubscription, deleteRule, deleteSending, enableRoutingSubdomain, ensureCatchAllRule, ensureEventSubscription, ensureSending, ensureWorkerRule, findZone, releaseCatchAllRule, routingState } from "./api.js";
6
6
  const NAME = /^(?=.{3,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
7
7
  /** A syntactically valid (lowercase) domain name. */
8
8
  export const isDomainName = (name) => NAME.test(name);
@@ -39,6 +39,7 @@ export async function syncDomain(c, a, deps) {
39
39
  let routingStatus;
40
40
  const ruleIds = [...(prev?.ruleIds ?? [])];
41
41
  let subscriptionId = prev?.subscriptionId;
42
+ let catchAll = prev?.catchAll;
42
43
  let recvOk = !wantRecv;
43
44
  if (wantRecv) {
44
45
  let r = await routingState(c, zone, name);
@@ -64,6 +65,27 @@ export async function syncDomain(c, a, deps) {
64
65
  notes.push(e.message);
65
66
  }
66
67
  }
68
+ if (deps.catchAll && name !== zone.name)
69
+ notes.push(`a catch-all is the zone's rule: only ${zone.name} can have one, not ${name}`);
70
+ else if (deps.catchAll) {
71
+ try {
72
+ await ensureCatchAllRule(c, zone.id, deps.worker);
73
+ catchAll = deps.worker;
74
+ }
75
+ catch (e) {
76
+ notes.push(`catch-all: ${e.message}`);
77
+ }
78
+ }
79
+ else if (catchAll && name === zone.name) {
80
+ // No catch-all mailbox any more: the rule this sync set goes back off.
81
+ try {
82
+ await releaseCatchAllRule(c, zone.id, catchAll);
83
+ catchAll = undefined;
84
+ }
85
+ catch (e) {
86
+ notes.push(`catch-all: ${e.message}`);
87
+ }
88
+ }
67
89
  }
68
90
  }
69
91
  if (wantSend && sendingOk) {
@@ -87,6 +109,7 @@ export async function syncDomain(c, a, deps) {
87
109
  ruleIds,
88
110
  ...(subscriptionId ? { subscriptionId } : {}),
89
111
  createdSending,
112
+ ...(catchAll ? { catchAll } : {}),
90
113
  notes,
91
114
  };
92
115
  }
@@ -107,6 +130,8 @@ export async function destroyDomain(c, d) {
107
130
  await attempt(`rule ${id}`, () => deleteRule(c, d.zoneId, id));
108
131
  if (d.createdSending && d.sendingTag)
109
132
  await attempt("sending domain", () => deleteSending(c, d.zoneId, d.sendingTag));
133
+ if (d.catchAll)
134
+ await attempt("catch-all", async () => void (await releaseCatchAllRule(c, d.zoneId, d.catchAll)));
110
135
  }
111
136
  if (d.subscriptionId)
112
137
  await attempt("event subscription", () => deleteEventSubscription(c, d.subscriptionId));
@@ -596,6 +596,7 @@ export declare const emailFunctions: {
596
596
  subject: string;
597
597
  html?: string;
598
598
  } | null;
599
+ catchAll?: boolean | null;
599
600
  }, GenericId<"mailboxes">>;
600
601
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
601
602
  name: string;
@@ -613,6 +614,7 @@ export declare const emailFunctions: {
613
614
  subject: string;
614
615
  html?: string;
615
616
  };
617
+ catchAll?: boolean;
616
618
  }[]>;
617
619
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
618
620
  to: string;
@@ -1403,6 +1405,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1403
1405
  subject: string;
1404
1406
  html?: string;
1405
1407
  } | null;
1408
+ catchAll?: boolean | null;
1406
1409
  }, GenericId<"mailboxes">>;
1407
1410
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
1408
1411
  name: string;
@@ -1420,6 +1423,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1420
1423
  subject: string;
1421
1424
  html?: string;
1422
1425
  };
1426
+ catchAll?: boolean;
1423
1427
  }[]>;
1424
1428
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
1425
1429
  to: string;
package/dist/component.js CHANGED
@@ -387,10 +387,18 @@ function applyToEmail(ctx, e, ev) {
387
387
  }
388
388
  // ------------------------------------------------------------------------------------------------ inbound
389
389
  const THREAD_WINDOW = 30 * DAY;
390
+ /** The catch-all mailbox of `domain`, if one is set. */
391
+ function catchAllOf(ctx, domain) {
392
+ return ctx.db.query("mailboxes").collect().find((m) => m.catchAll && domainOf(m.address) === domain) ?? null;
393
+ }
390
394
  function routeOf(ctx, to) {
391
395
  const { base, tag } = splitPlus(to.toLowerCase());
392
396
  const m = ctx.db.query("mailboxes").withIndex("by_address", (q) => q.eq("address", base)).first();
393
- return m ? { mailbox: m.name, tag } : null;
397
+ if (m)
398
+ return { mailbox: m.name, tag };
399
+ // No mailbox has that address: its domain's catch-all takes it (the address it was sent to stays in its To).
400
+ const all = catchAllOf(ctx, domainOf(base));
401
+ return all ? { mailbox: all.name, tag: null } : null;
394
402
  }
395
403
  function token(ctx) {
396
404
  const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -842,6 +850,8 @@ export const emailFunctions = {
842
850
  displayName: v.optional(v.union(v.null(), v.string())),
843
851
  /** Absent: keeps the stored one; null: clears it. */
844
852
  autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
853
+ /** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
854
+ catchAll: v.optional(v.union(v.null(), v.boolean())),
845
855
  },
846
856
  handler: (ctx, a) => {
847
857
  if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
@@ -856,7 +866,21 @@ export const emailFunctions = {
856
866
  // Patch semantics: an absent field keeps its stored value, null clears it.
857
867
  const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
858
868
  const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
859
- const doc = { name: a.name, address, ...(displayName ? { displayName } : {}), ...(autoReply ? { autoReply } : {}), nextNumber: prev?.nextNumber ?? 1, createdAt: prev?.createdAt ?? ctx.now };
869
+ const catchAll = a.catchAll === undefined ? !!prev?.catchAll : !!a.catchAll;
870
+ if (catchAll) {
871
+ const other = catchAllOf(ctx, domainOf(address));
872
+ if (other && other.name !== a.name)
873
+ fail("ValidationError", `${domainOf(address)} already has a catch-all: mailbox ${other.name}`);
874
+ }
875
+ const doc = {
876
+ name: a.name,
877
+ address,
878
+ ...(displayName ? { displayName } : {}),
879
+ ...(autoReply ? { autoReply } : {}),
880
+ ...(catchAll ? { catchAll: true } : {}),
881
+ nextNumber: prev?.nextNumber ?? 1,
882
+ createdAt: prev?.createdAt ?? ctx.now,
883
+ };
860
884
  if (prev) {
861
885
  ctx.db.replace(prev._id, doc);
862
886
  return prev._id;
package/dist/schema.d.ts CHANGED
@@ -169,6 +169,8 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
169
169
  text: import("@freecorps/ember-values").Validator<string, "required", never>;
170
170
  html: import("@freecorps/ember-values").Validator<string, "optional", never>;
171
171
  }> | undefined>>;
172
+ /** Receives mail for any address of its domain that no other mailbox has (one per domain). */
173
+ catchAll: import("@freecorps/ember-values").Validator<boolean, "optional", never>;
172
174
  nextNumber: import("@freecorps/ember-values").Validator<number, "required", never>;
173
175
  createdAt: import("@freecorps/ember-values").Validator<number, "required", never>;
174
176
  }>, {
package/dist/schema.js CHANGED
@@ -93,6 +93,8 @@ export const emailSchema = defineSchema({
93
93
  address: v.string(),
94
94
  displayName: v.optional(v.string()),
95
95
  autoReply: v.optional(v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) })),
96
+ /** Receives mail for any address of its domain that no other mailbox has (one per domain). */
97
+ catchAll: v.optional(v.boolean()),
96
98
  nextNumber: v.number(),
97
99
  createdAt: v.number(),
98
100
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecorps/ember-email",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Email component for Ember on Cloudflare: send (atomic from mutations, scheduled, idempotent) through Email Service, templates, suppressions and per-project rate limits, delivery events from the Queue, inbound mail (Email Routing) parsed into mailboxes and threads. `@freecorps/ember-email/cloudflare` onboards a domain (sending, routing rules, event subscription) and removes it.",
5
5
  "keywords": [
6
6
  "ember",
@@ -52,9 +52,9 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
- "@freecorps/ember-rate-limit": "0.5.6",
56
- "@freecorps/ember-server": "0.5.6",
57
- "@freecorps/ember-values": "0.5.6",
55
+ "@freecorps/ember-rate-limit": "0.5.7",
56
+ "@freecorps/ember-server": "0.5.7",
57
+ "@freecorps/ember-values": "0.5.7",
58
58
  "postal-mime": "^4.1.4"
59
59
  }
60
60
  }