@freecorps/ember-email 0.5.6 → 0.5.8

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
@@ -71,6 +71,10 @@ export const signup = mutation({
71
71
  (with their size). Mind the provider's message size: for a large file, put a download link in the text
72
72
  (`ctx.storage.getUrl(file, { download, expiresIn })`) and pass it with `link: true`. The email then has no MIME part
73
73
  for it, and the message still holds the file (the thread lists it, and it is not collected).
74
+ - **Retention:** `upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })` keeps closed and spam conversations
75
+ that many days, counted from the latest status change or message; a daily job (`lib:purge`, 04:00
76
+ UTC) then deletes the conversation and its messages, and file GC deletes their `.eml` and attachments. Without
77
+ `retention` (or with `retention: null`) nothing is deleted.
74
78
 
75
79
  ## Domains: `@freecorps/ember-email/cloudflare`
76
80
 
@@ -97,6 +101,10 @@ What it does:
97
101
  routing subdomain, so removal is a dashboard step.
98
102
  - **Mailboxes:** it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule
99
103
  that routes somewhere else.
104
+ - **Catch-all:** with `catchAll: true` (a mailbox of the domain is its catch-all, `upsertMailbox({ catchAll: true })`),
105
+ the zone's catch-all rule → your Worker, so every address no mailbox has reaches that mailbox, with the address it
106
+ 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
107
+ that routes somewhere else. `destroyDomain`, or a sync without `catchAll`, turns it back off.
100
108
  - **Events:** it adds an `email.sending` event subscription → the component's queue.
101
109
 
102
110
  ## Offline replicas: `@freecorps/ember-email/schema`
package/dist/client.d.ts CHANGED
@@ -2,6 +2,11 @@ import type { FunctionReference } from "@freecorps/ember-server";
2
2
  import type { SendInput, SendResult } from "./component.ts";
3
3
  import type { EmailStatus } from "./events.ts";
4
4
  type Ref<T extends "query" | "mutation" | "action", A, R> = FunctionReference<T, "public", A, R>;
5
+ /** Days closed and spam conversations are kept (`upsertMailbox({ retention })`); a missing kind is kept forever. */
6
+ export type MailboxRetention = {
7
+ closedDays?: number;
8
+ spamDays?: number;
9
+ };
5
10
  type Page<T> = {
6
11
  page: T[];
7
12
  isDone: boolean;
@@ -133,6 +138,8 @@ export interface EmailComponentApi {
133
138
  text: string;
134
139
  html?: string;
135
140
  } | null;
141
+ catchAll?: boolean | null;
142
+ retention?: MailboxRetention | null;
136
143
  }, string>;
137
144
  removeMailbox: Ref<"mutation", {
138
145
  name: string;
@@ -143,6 +150,8 @@ export interface EmailComponentApi {
143
150
  address: string;
144
151
  displayName?: string;
145
152
  autoReply?: unknown;
153
+ catchAll?: boolean;
154
+ retention?: MailboxRetention;
146
155
  }>>;
147
156
  route: Ref<"query", {
148
157
  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));
@@ -513,6 +513,15 @@ export declare const emailFunctions: {
513
513
  } | null>;
514
514
  /** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
515
515
  sweep: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, number>;
516
+ /**
517
+ * Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
518
+ * messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
519
+ */
520
+ purge: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, {
521
+ threads: number;
522
+ messages: number;
523
+ more: boolean;
524
+ }>;
516
525
  applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
517
526
  events: any[];
518
527
  }, ({
@@ -596,6 +605,11 @@ export declare const emailFunctions: {
596
605
  subject: string;
597
606
  html?: string;
598
607
  } | null;
608
+ catchAll?: boolean | null;
609
+ retention?: {
610
+ closedDays?: number;
611
+ spamDays?: number;
612
+ } | null;
599
613
  }, GenericId<"mailboxes">>;
600
614
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
601
615
  name: string;
@@ -613,6 +627,11 @@ export declare const emailFunctions: {
613
627
  subject: string;
614
628
  html?: string;
615
629
  };
630
+ catchAll?: boolean;
631
+ retention?: {
632
+ closedDays?: number;
633
+ spamDays?: number;
634
+ };
616
635
  }[]>;
617
636
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
618
637
  to: string;
@@ -642,6 +661,7 @@ export declare const emailFunctions: {
642
661
  lastMessageAt: number;
643
662
  snippet: string;
644
663
  contactName?: string;
664
+ activityAt?: number;
645
665
  lastInboundAt?: number;
646
666
  assignee?: string;
647
667
  assigneeName?: string;
@@ -664,6 +684,7 @@ export declare const emailFunctions: {
664
684
  lastMessageAt: number;
665
685
  snippet: string;
666
686
  contactName?: string;
687
+ activityAt?: number;
667
688
  lastInboundAt?: number;
668
689
  assignee?: string;
669
690
  assigneeName?: string;
@@ -828,6 +849,7 @@ export declare const emailFunctions: {
828
849
  lastMessageAt: number;
829
850
  snippet: string;
830
851
  contactName?: string;
852
+ activityAt?: number;
831
853
  lastInboundAt?: number;
832
854
  assignee?: string;
833
855
  assigneeName?: string;
@@ -1320,6 +1342,15 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1320
1342
  } | null>;
1321
1343
  /** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
1322
1344
  sweep: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, number>;
1345
+ /**
1346
+ * Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
1347
+ * messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
1348
+ */
1349
+ purge: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, {
1350
+ threads: number;
1351
+ messages: number;
1352
+ more: boolean;
1353
+ }>;
1323
1354
  applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
1324
1355
  events: any[];
1325
1356
  }, ({
@@ -1403,6 +1434,11 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1403
1434
  subject: string;
1404
1435
  html?: string;
1405
1436
  } | null;
1437
+ catchAll?: boolean | null;
1438
+ retention?: {
1439
+ closedDays?: number;
1440
+ spamDays?: number;
1441
+ } | null;
1406
1442
  }, GenericId<"mailboxes">>;
1407
1443
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
1408
1444
  name: string;
@@ -1420,6 +1456,11 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1420
1456
  subject: string;
1421
1457
  html?: string;
1422
1458
  };
1459
+ catchAll?: boolean;
1460
+ retention?: {
1461
+ closedDays?: number;
1462
+ spamDays?: number;
1463
+ };
1423
1464
  }[]>;
1424
1465
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
1425
1466
  to: string;
@@ -1449,6 +1490,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1449
1490
  lastMessageAt: number;
1450
1491
  snippet: string;
1451
1492
  contactName?: string;
1493
+ activityAt?: number;
1452
1494
  lastInboundAt?: number;
1453
1495
  assignee?: string;
1454
1496
  assigneeName?: string;
@@ -1471,6 +1513,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1471
1513
  lastMessageAt: number;
1472
1514
  snippet: string;
1473
1515
  contactName?: string;
1516
+ activityAt?: number;
1474
1517
  lastInboundAt?: number;
1475
1518
  assignee?: string;
1476
1519
  assigneeName?: string;
@@ -1635,6 +1678,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1635
1678
  lastMessageAt: number;
1636
1679
  snippet: string;
1637
1680
  contactName?: string;
1681
+ activityAt?: number;
1638
1682
  lastInboundAt?: number;
1639
1683
  assignee?: string;
1640
1684
  assigneeName?: 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";
@@ -469,6 +477,7 @@ function ingest(ctx, a) {
469
477
  contact,
470
478
  ...((a.replyTo ?? a.from).name ? { contactName: (a.replyTo ?? a.from).name } : {}),
471
479
  status: spam ? "spam" : "open",
480
+ activityAt: ctx.now,
472
481
  token: token(ctx),
473
482
  messageCount: 0,
474
483
  unreadCount: 0,
@@ -505,6 +514,7 @@ function ingest(ctx, a) {
505
514
  const status = thread.status === "spam" ? "spam" : "open";
506
515
  ctx.db.patch(thread._id, {
507
516
  status,
517
+ activityAt: ctx.now,
508
518
  messageCount: thread.messageCount + 1,
509
519
  unreadCount: thread.unreadCount + 1,
510
520
  lastMessageAt: ctx.now,
@@ -593,10 +603,83 @@ function appendOutbound(ctx, thread, mb, a) {
593
603
  ctx.db.patch(thread._id, {
594
604
  messageCount: thread.messageCount + 1,
595
605
  lastMessageAt: ctx.now,
606
+ activityAt: ctx.now,
596
607
  ...(a.note ? {} : { status: thread.status === "spam" ? "spam" : "waiting", snippet }),
597
608
  });
598
609
  return id;
599
610
  }
611
+ const DAY_MS = 86_400_000;
612
+ const MAX_RETENTION_DAYS = 36_500;
613
+ /** Rows (messages and threads) one purge transaction deletes before it schedules the rest. */
614
+ const PURGE_BUDGET = 500;
615
+ /** Candidates one purge run looks at per mailbox and status (some may not be due yet: see `purgeExpired`). */
616
+ const PURGE_SCAN = 200;
617
+ function retentionOf(r) {
618
+ const out = {};
619
+ for (const k of ["closedDays", "spamDays"]) {
620
+ const d = r[k];
621
+ if (d === undefined)
622
+ continue;
623
+ if (!Number.isInteger(d) || d < 1 || d > MAX_RETENTION_DAYS)
624
+ fail("ValidationError", `retention.${k}: a whole number of days in 1..${MAX_RETENTION_DAYS}`);
625
+ out[k] = d;
626
+ }
627
+ return out.closedDays === undefined && out.spamDays === undefined ? undefined : out;
628
+ }
629
+ /**
630
+ * One purge pass: the closed and spam threads whose latest status change or message (`activityAt`) is older than
631
+ * their mailbox's retention, straight from the `by_mailbox_status_activity` index (every candidate is due, so a
632
+ * thread kept longer never blocks the ones behind it). Threads from before `activityAt` (absent, first in the index)
633
+ * get it from `lastMessageAt` when they are not due yet, so they leave the head of the index.
634
+ */
635
+ function purgeExpired(ctx) {
636
+ let budget = PURGE_BUDGET;
637
+ let threads = 0;
638
+ let messages = 0;
639
+ let more = false;
640
+ for (const mb of ctx.db.query("mailboxes").collect()) {
641
+ for (const [status, days] of [
642
+ ["closed", mb.retention?.closedDays],
643
+ ["spam", mb.retention?.spamDays],
644
+ ]) {
645
+ if (!days || more)
646
+ continue;
647
+ const cutoff = ctx.now - days * DAY_MS;
648
+ const due = ctx.db
649
+ .query("threads")
650
+ .withIndex("by_mailbox_status_activity", (q) => q.eq("mailbox", mb.name).eq("status", status).lte("activityAt", cutoff))
651
+ .take(PURGE_SCAN);
652
+ for (const t of due) {
653
+ if (budget <= 0) {
654
+ more = true;
655
+ break;
656
+ }
657
+ if (t.activityAt === undefined && t.lastMessageAt > cutoff) {
658
+ ctx.db.patch(t._id, { activityAt: t.lastMessageAt });
659
+ budget--;
660
+ continue;
661
+ }
662
+ const batch = ctx.db.query("messages").withIndex("by_thread", (q) => q.eq("threadId", t._id)).take(budget);
663
+ for (const m of batch)
664
+ ctx.db.delete(m._id);
665
+ messages += batch.length;
666
+ budget -= batch.length;
667
+ if (budget <= 0) {
668
+ more = true; // the thread goes on the next run, once its last messages are gone
669
+ break;
670
+ }
671
+ ctx.db.delete(t._id);
672
+ threads++;
673
+ budget--;
674
+ }
675
+ if (due.length === PURGE_SCAN)
676
+ more = true;
677
+ }
678
+ }
679
+ if (more)
680
+ ctx.scheduler.runAfter(0, "lib:purge", {});
681
+ return { threads, messages, more };
682
+ }
600
683
  // ------------------------------------------------------------------------------------------------ functions
601
684
  const pageV = v.optional(v.object({ numItems: v.number(), cursor: v.union(v.string(), v.null()) }));
602
685
  export const emailFunctions = {
@@ -757,6 +840,14 @@ export const emailFunctions = {
757
840
  return n;
758
841
  },
759
842
  }),
843
+ /**
844
+ * Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
845
+ * messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
846
+ */
847
+ purge: internalMutation({
848
+ args: {},
849
+ handler: (ctx) => purgeExpired(ctx),
850
+ }),
760
851
  // ---------------------------------------------------------------- events
761
852
  applyEvents: internalMutation({
762
853
  args: { events: v.array(v.any()) },
@@ -842,6 +933,13 @@ export const emailFunctions = {
842
933
  displayName: v.optional(v.union(v.null(), v.string())),
843
934
  /** Absent: keeps the stored one; null: clears it. */
844
935
  autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
936
+ /** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
937
+ catchAll: v.optional(v.union(v.null(), v.boolean())),
938
+ /**
939
+ * Days closed and spam conversations are kept (whole days, 1..36500; a missing kind is kept forever).
940
+ * Absent: keeps the stored policy; null: clears it (kept forever).
941
+ */
942
+ retention: v.optional(v.union(v.null(), v.object({ closedDays: v.optional(v.number()), spamDays: v.optional(v.number()) }))),
845
943
  },
846
944
  handler: (ctx, a) => {
847
945
  if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
@@ -856,7 +954,23 @@ export const emailFunctions = {
856
954
  // Patch semantics: an absent field keeps its stored value, null clears it.
857
955
  const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
858
956
  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 };
957
+ const catchAll = a.catchAll === undefined ? !!prev?.catchAll : !!a.catchAll;
958
+ const retention = a.retention === null ? undefined : a.retention === undefined ? prev?.retention : retentionOf(a.retention);
959
+ if (catchAll) {
960
+ const other = catchAllOf(ctx, domainOf(address));
961
+ if (other && other.name !== a.name)
962
+ fail("ValidationError", `${domainOf(address)} already has a catch-all: mailbox ${other.name}`);
963
+ }
964
+ const doc = {
965
+ name: a.name,
966
+ address,
967
+ ...(displayName ? { displayName } : {}),
968
+ ...(autoReply ? { autoReply } : {}),
969
+ ...(catchAll ? { catchAll: true } : {}),
970
+ ...(retention ? { retention } : {}),
971
+ nextNumber: prev?.nextNumber ?? 1,
972
+ createdAt: prev?.createdAt ?? ctx.now,
973
+ };
860
974
  if (prev) {
861
975
  ctx.db.replace(prev._id, doc);
862
976
  return prev._id;
@@ -1013,7 +1127,8 @@ export const emailFunctions = {
1013
1127
  const t = ctx.db.get(a.threadId);
1014
1128
  if (!t)
1015
1129
  return fail("NotFound", "thread not found");
1016
- ctx.db.patch(a.threadId, { status: a.status });
1130
+ if (t.status !== a.status)
1131
+ ctx.db.patch(a.threadId, { status: a.status, activityAt: ctx.now });
1017
1132
  return a.status;
1018
1133
  },
1019
1134
  }),
@@ -1139,6 +1254,7 @@ export const emailFunctions = {
1139
1254
  messageCount: 0,
1140
1255
  unreadCount: 0,
1141
1256
  lastMessageAt: ctx.now,
1257
+ activityAt: ctx.now,
1142
1258
  snippet: snippetOf(a.text),
1143
1259
  });
1144
1260
  const thread = ctx.db.get(threadId);
@@ -1265,7 +1381,7 @@ export const email = defineComponent({
1265
1381
  bindings: (config) => (config?.transport ?? "binding") === "binding"
1266
1382
  ? [{ type: "send_email", name: config?.binding ?? "EMAIL", ...(config?.allowedSenders?.length ? { allowed_sender_addresses: config.allowedSenders } : {}) }]
1267
1383
  : [],
1268
- crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep"),
1384
+ crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep").daily("purge", { hour: 4 }, "lib:purge"),
1269
1385
  queues: {
1270
1386
  events: { shard: (config) => config?.shard ?? "global:main", consumer: { maxBatchSize: 100, maxBatchTimeoutSeconds: 2, maxRetries: 10 }, handler: onEvents },
1271
1387
  },
package/dist/schema.d.ts CHANGED
@@ -169,6 +169,19 @@ 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>;
174
+ /**
175
+ * How long closed and spam conversations are kept, in days, counted from the latest status change or message
176
+ * (`threads.activityAt`). Absent: kept forever. A daily job deletes the conversation, its messages and their files.
177
+ */
178
+ retention: import("@freecorps/ember-values").Validator<{
179
+ closedDays?: number;
180
+ spamDays?: number;
181
+ }, "optional", NonNullable<import("@freecorps/ember-values").ObjectFieldPaths<{
182
+ closedDays: import("@freecorps/ember-values").Validator<number, "optional", never>;
183
+ spamDays: import("@freecorps/ember-values").Validator<number, "optional", never>;
184
+ }> | undefined>>;
172
185
  nextNumber: import("@freecorps/ember-values").Validator<number, "required", never>;
173
186
  createdAt: import("@freecorps/ember-values").Validator<number, "required", never>;
174
187
  }>, {
@@ -185,6 +198,11 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
185
198
  contactName: import("@freecorps/ember-values").Validator<string, "optional", never>;
186
199
  /** open | waiting | closed | spam */
187
200
  status: import("@freecorps/ember-values").Validator<string, "required", never>;
201
+ /**
202
+ * The latest status change or message: retention counts from it. Absent on threads from before 0.5.8 (the first
203
+ * purge fills it in from `lastMessageAt`).
204
+ */
205
+ activityAt: import("@freecorps/ember-values").Validator<number, "optional", never>;
188
206
  /** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
189
207
  token: import("@freecorps/ember-values").Validator<string, "required", never>;
190
208
  messageCount: import("@freecorps/ember-values").Validator<number, "required", never>;
@@ -198,6 +216,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
198
216
  }>, {
199
217
  by_mailbox_last: ["mailbox", "lastMessageAt", "_creationTime"];
200
218
  by_mailbox_status: ["mailbox", "status", "lastMessageAt", "_creationTime"];
219
+ by_mailbox_status_activity: ["mailbox", "status", "activityAt", "_creationTime"];
201
220
  by_token: ["token", "_creationTime"];
202
221
  by_contact_subject: ["mailbox", "contact", "subjectKey", "_creationTime"];
203
222
  }, {
package/dist/schema.js CHANGED
@@ -93,6 +93,13 @@ 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()),
98
+ /**
99
+ * How long closed and spam conversations are kept, in days, counted from the latest status change or message
100
+ * (`threads.activityAt`). Absent: kept forever. A daily job deletes the conversation, its messages and their files.
101
+ */
102
+ retention: v.optional(v.object({ closedDays: v.optional(v.number()), spamDays: v.optional(v.number()) })),
96
103
  nextNumber: v.number(),
97
104
  createdAt: v.number(),
98
105
  })
@@ -108,6 +115,11 @@ export const emailSchema = defineSchema({
108
115
  contactName: v.optional(v.string()),
109
116
  /** open | waiting | closed | spam */
110
117
  status: v.string(),
118
+ /**
119
+ * The latest status change or message: retention counts from it. Absent on threads from before 0.5.8 (the first
120
+ * purge fills it in from `lastMessageAt`).
121
+ */
122
+ activityAt: v.optional(v.number()),
111
123
  /** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
112
124
  token: v.string(),
113
125
  messageCount: v.number(),
@@ -121,6 +133,7 @@ export const emailSchema = defineSchema({
121
133
  })
122
134
  .index("by_mailbox_last", ["mailbox", "lastMessageAt"])
123
135
  .index("by_mailbox_status", ["mailbox", "status", "lastMessageAt"])
136
+ .index("by_mailbox_status_activity", ["mailbox", "status", "activityAt"])
124
137
  .index("by_token", ["token"])
125
138
  .index("by_contact_subject", ["mailbox", "contact", "subjectKey"])
126
139
  .aggregate("unread", { by: ["mailbox"], count: true, sum: ["unreadCount"] }),
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.8",
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.8",
56
+ "@freecorps/ember-server": "0.5.8",
57
+ "@freecorps/ember-values": "0.5.8",
58
58
  "postal-mime": "^4.1.4"
59
59
  }
60
60
  }