@freecorps/ember-email 0.5.7 → 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
 
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;
@@ -134,6 +139,7 @@ export interface EmailComponentApi {
134
139
  html?: string;
135
140
  } | null;
136
141
  catchAll?: boolean | null;
142
+ retention?: MailboxRetention | null;
137
143
  }, string>;
138
144
  removeMailbox: Ref<"mutation", {
139
145
  name: string;
@@ -145,6 +151,7 @@ export interface EmailComponentApi {
145
151
  displayName?: string;
146
152
  autoReply?: unknown;
147
153
  catchAll?: boolean;
154
+ retention?: MailboxRetention;
148
155
  }>>;
149
156
  route: Ref<"query", {
150
157
  to: string;
@@ -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
  }, ({
@@ -597,6 +606,10 @@ export declare const emailFunctions: {
597
606
  html?: string;
598
607
  } | null;
599
608
  catchAll?: boolean | null;
609
+ retention?: {
610
+ closedDays?: number;
611
+ spamDays?: number;
612
+ } | null;
600
613
  }, GenericId<"mailboxes">>;
601
614
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
602
615
  name: string;
@@ -615,6 +628,10 @@ export declare const emailFunctions: {
615
628
  html?: string;
616
629
  };
617
630
  catchAll?: boolean;
631
+ retention?: {
632
+ closedDays?: number;
633
+ spamDays?: number;
634
+ };
618
635
  }[]>;
619
636
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
620
637
  to: string;
@@ -644,6 +661,7 @@ export declare const emailFunctions: {
644
661
  lastMessageAt: number;
645
662
  snippet: string;
646
663
  contactName?: string;
664
+ activityAt?: number;
647
665
  lastInboundAt?: number;
648
666
  assignee?: string;
649
667
  assigneeName?: string;
@@ -666,6 +684,7 @@ export declare const emailFunctions: {
666
684
  lastMessageAt: number;
667
685
  snippet: string;
668
686
  contactName?: string;
687
+ activityAt?: number;
669
688
  lastInboundAt?: number;
670
689
  assignee?: string;
671
690
  assigneeName?: string;
@@ -830,6 +849,7 @@ export declare const emailFunctions: {
830
849
  lastMessageAt: number;
831
850
  snippet: string;
832
851
  contactName?: string;
852
+ activityAt?: number;
833
853
  lastInboundAt?: number;
834
854
  assignee?: string;
835
855
  assigneeName?: string;
@@ -1322,6 +1342,15 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1322
1342
  } | null>;
1323
1343
  /** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
1324
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
+ }>;
1325
1354
  applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
1326
1355
  events: any[];
1327
1356
  }, ({
@@ -1406,6 +1435,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1406
1435
  html?: string;
1407
1436
  } | null;
1408
1437
  catchAll?: boolean | null;
1438
+ retention?: {
1439
+ closedDays?: number;
1440
+ spamDays?: number;
1441
+ } | null;
1409
1442
  }, GenericId<"mailboxes">>;
1410
1443
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
1411
1444
  name: string;
@@ -1424,6 +1457,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1424
1457
  html?: string;
1425
1458
  };
1426
1459
  catchAll?: boolean;
1460
+ retention?: {
1461
+ closedDays?: number;
1462
+ spamDays?: number;
1463
+ };
1427
1464
  }[]>;
1428
1465
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
1429
1466
  to: string;
@@ -1453,6 +1490,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1453
1490
  lastMessageAt: number;
1454
1491
  snippet: string;
1455
1492
  contactName?: string;
1493
+ activityAt?: number;
1456
1494
  lastInboundAt?: number;
1457
1495
  assignee?: string;
1458
1496
  assigneeName?: string;
@@ -1475,6 +1513,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1475
1513
  lastMessageAt: number;
1476
1514
  snippet: string;
1477
1515
  contactName?: string;
1516
+ activityAt?: number;
1478
1517
  lastInboundAt?: number;
1479
1518
  assignee?: string;
1480
1519
  assigneeName?: string;
@@ -1639,6 +1678,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1639
1678
  lastMessageAt: number;
1640
1679
  snippet: string;
1641
1680
  contactName?: string;
1681
+ activityAt?: number;
1642
1682
  lastInboundAt?: number;
1643
1683
  assignee?: string;
1644
1684
  assigneeName?: string;
package/dist/component.js CHANGED
@@ -477,6 +477,7 @@ function ingest(ctx, a) {
477
477
  contact,
478
478
  ...((a.replyTo ?? a.from).name ? { contactName: (a.replyTo ?? a.from).name } : {}),
479
479
  status: spam ? "spam" : "open",
480
+ activityAt: ctx.now,
480
481
  token: token(ctx),
481
482
  messageCount: 0,
482
483
  unreadCount: 0,
@@ -513,6 +514,7 @@ function ingest(ctx, a) {
513
514
  const status = thread.status === "spam" ? "spam" : "open";
514
515
  ctx.db.patch(thread._id, {
515
516
  status,
517
+ activityAt: ctx.now,
516
518
  messageCount: thread.messageCount + 1,
517
519
  unreadCount: thread.unreadCount + 1,
518
520
  lastMessageAt: ctx.now,
@@ -601,10 +603,83 @@ function appendOutbound(ctx, thread, mb, a) {
601
603
  ctx.db.patch(thread._id, {
602
604
  messageCount: thread.messageCount + 1,
603
605
  lastMessageAt: ctx.now,
606
+ activityAt: ctx.now,
604
607
  ...(a.note ? {} : { status: thread.status === "spam" ? "spam" : "waiting", snippet }),
605
608
  });
606
609
  return id;
607
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
+ }
608
683
  // ------------------------------------------------------------------------------------------------ functions
609
684
  const pageV = v.optional(v.object({ numItems: v.number(), cursor: v.union(v.string(), v.null()) }));
610
685
  export const emailFunctions = {
@@ -765,6 +840,14 @@ export const emailFunctions = {
765
840
  return n;
766
841
  },
767
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
+ }),
768
851
  // ---------------------------------------------------------------- events
769
852
  applyEvents: internalMutation({
770
853
  args: { events: v.array(v.any()) },
@@ -852,6 +935,11 @@ export const emailFunctions = {
852
935
  autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
853
936
  /** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
854
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()) }))),
855
943
  },
856
944
  handler: (ctx, a) => {
857
945
  if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
@@ -867,6 +955,7 @@ export const emailFunctions = {
867
955
  const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
868
956
  const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
869
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);
870
959
  if (catchAll) {
871
960
  const other = catchAllOf(ctx, domainOf(address));
872
961
  if (other && other.name !== a.name)
@@ -878,6 +967,7 @@ export const emailFunctions = {
878
967
  ...(displayName ? { displayName } : {}),
879
968
  ...(autoReply ? { autoReply } : {}),
880
969
  ...(catchAll ? { catchAll: true } : {}),
970
+ ...(retention ? { retention } : {}),
881
971
  nextNumber: prev?.nextNumber ?? 1,
882
972
  createdAt: prev?.createdAt ?? ctx.now,
883
973
  };
@@ -1037,7 +1127,8 @@ export const emailFunctions = {
1037
1127
  const t = ctx.db.get(a.threadId);
1038
1128
  if (!t)
1039
1129
  return fail("NotFound", "thread not found");
1040
- 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 });
1041
1132
  return a.status;
1042
1133
  },
1043
1134
  }),
@@ -1163,6 +1254,7 @@ export const emailFunctions = {
1163
1254
  messageCount: 0,
1164
1255
  unreadCount: 0,
1165
1256
  lastMessageAt: ctx.now,
1257
+ activityAt: ctx.now,
1166
1258
  snippet: snippetOf(a.text),
1167
1259
  });
1168
1260
  const thread = ctx.db.get(threadId);
@@ -1289,7 +1381,7 @@ export const email = defineComponent({
1289
1381
  bindings: (config) => (config?.transport ?? "binding") === "binding"
1290
1382
  ? [{ type: "send_email", name: config?.binding ?? "EMAIL", ...(config?.allowedSenders?.length ? { allowed_sender_addresses: config.allowedSenders } : {}) }]
1291
1383
  : [],
1292
- 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"),
1293
1385
  queues: {
1294
1386
  events: { shard: (config) => config?.shard ?? "global:main", consumer: { maxBatchSize: 100, maxBatchTimeoutSeconds: 2, maxRetries: 10 }, handler: onEvents },
1295
1387
  },
package/dist/schema.d.ts CHANGED
@@ -171,6 +171,17 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
171
171
  }> | undefined>>;
172
172
  /** Receives mail for any address of its domain that no other mailbox has (one per domain). */
173
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>>;
174
185
  nextNumber: import("@freecorps/ember-values").Validator<number, "required", never>;
175
186
  createdAt: import("@freecorps/ember-values").Validator<number, "required", never>;
176
187
  }>, {
@@ -187,6 +198,11 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
187
198
  contactName: import("@freecorps/ember-values").Validator<string, "optional", never>;
188
199
  /** open | waiting | closed | spam */
189
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>;
190
206
  /** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
191
207
  token: import("@freecorps/ember-values").Validator<string, "required", never>;
192
208
  messageCount: import("@freecorps/ember-values").Validator<number, "required", never>;
@@ -200,6 +216,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
200
216
  }>, {
201
217
  by_mailbox_last: ["mailbox", "lastMessageAt", "_creationTime"];
202
218
  by_mailbox_status: ["mailbox", "status", "lastMessageAt", "_creationTime"];
219
+ by_mailbox_status_activity: ["mailbox", "status", "activityAt", "_creationTime"];
203
220
  by_token: ["token", "_creationTime"];
204
221
  by_contact_subject: ["mailbox", "contact", "subjectKey", "_creationTime"];
205
222
  }, {
package/dist/schema.js CHANGED
@@ -95,6 +95,11 @@ export const emailSchema = defineSchema({
95
95
  autoReply: v.optional(v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) })),
96
96
  /** Receives mail for any address of its domain that no other mailbox has (one per domain). */
97
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()) })),
98
103
  nextNumber: v.number(),
99
104
  createdAt: v.number(),
100
105
  })
@@ -110,6 +115,11 @@ export const emailSchema = defineSchema({
110
115
  contactName: v.optional(v.string()),
111
116
  /** open | waiting | closed | spam */
112
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()),
113
123
  /** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
114
124
  token: v.string(),
115
125
  messageCount: v.number(),
@@ -123,6 +133,7 @@ export const emailSchema = defineSchema({
123
133
  })
124
134
  .index("by_mailbox_last", ["mailbox", "lastMessageAt"])
125
135
  .index("by_mailbox_status", ["mailbox", "status", "lastMessageAt"])
136
+ .index("by_mailbox_status_activity", ["mailbox", "status", "activityAt"])
126
137
  .index("by_token", ["token"])
127
138
  .index("by_contact_subject", ["mailbox", "contact", "subjectKey"])
128
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.7",
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.7",
56
- "@freecorps/ember-server": "0.5.7",
57
- "@freecorps/ember-values": "0.5.7",
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
  }