@freecorps/ember-email 0.5.7 → 0.5.9

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
@@ -60,6 +60,8 @@ export const signup = mutation({
60
60
  - a plus-addressed token (`support+t_…@`) still threads from any sender.
61
61
  - `replyToken: true` in the install config puts that token in the Reply-To of replies, composed emails and
62
62
  auto-replies, as before 0.5.4.
63
+ - Replies and auto-replies cite the Message-ID and References exactly as the sender wrote them (Gmail only groups a
64
+ reply in the conversation on an exact match); lookups ignore case.
63
65
  - It handles spam per sender per hour and auto-replies without loops.
64
66
  - Your `onInbound` hook runs in the same transaction (tickets, push).
65
67
  - **Inbox queries** for dashboards and mobile apps: `threads`, `thread`, `unread` (aggregate), `search`
@@ -71,6 +73,10 @@ export const signup = mutation({
71
73
  (with their size). Mind the provider's message size: for a large file, put a download link in the text
72
74
  (`ctx.storage.getUrl(file, { download, expiresIn })`) and pass it with `link: true`. The email then has no MIME part
73
75
  for it, and the message still holds the file (the thread lists it, and it is not collected).
76
+ - **Retention:** `upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })` keeps closed and spam conversations
77
+ that many days, counted from the latest status change or message; a daily job (`lib:purge`, 04:00
78
+ UTC) then deletes the conversation and its messages, and file GC deletes their `.eml` and attachments. Without
79
+ `retention` (or with `retention: null`) nothing is deleted.
74
80
 
75
81
  ## Domains: `@freecorps/ember-email/cloudflare`
76
82
 
package/dist/address.d.ts CHANGED
@@ -28,9 +28,13 @@ export declare function withPlus(email: string, tag: string): string;
28
28
  export declare function subjectKey(subject: string | undefined): string;
29
29
  /** "Hello" → "Re: Hello" (once). */
30
30
  export declare function replySubject(subject: string): string;
31
+ /** A `Message-ID` value without the angle brackets, as written (the case kept: headers that cite it must match it). */
32
+ export declare function bareMessageId(id: string | undefined | null): string | null;
31
33
  /** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
32
34
  export declare function normalizeMessageId(id: string | undefined | null): string | null;
33
- /** All Message-IDs in a `References` / `In-Reply-To` header value. */
35
+ /** All Message-IDs in a `References` / `In-Reply-To` header value, as written. */
36
+ export declare function bareMessageIdList(h: string | undefined | null): string[];
37
+ /** All Message-IDs in a `References` / `In-Reply-To` header value, lowercased (header lookups). */
34
38
  export declare function messageIdList(h: string | undefined | null): string[];
35
39
  /** Addresses that must never get an automatic reply. */
36
40
  export declare function isNoReplySender(email: string): boolean;
package/dist/address.js CHANGED
@@ -93,27 +93,35 @@ export function subjectKey(subject) {
93
93
  export function replySubject(subject) {
94
94
  return /^\s*re\s*:/i.test(subject) ? subject : `Re: ${subject}`;
95
95
  }
96
- /** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
97
- export function normalizeMessageId(id) {
96
+ /** A `Message-ID` value without the angle brackets, as written (the case kept: headers that cite it must match it). */
97
+ export function bareMessageId(id) {
98
98
  if (!id)
99
99
  return null;
100
100
  const m = /<([^<>]+)>/.exec(id);
101
101
  const v = (m ? m[1] : id).trim();
102
- return v ? v.toLowerCase() : null;
102
+ return v || null;
103
103
  }
104
- /** All Message-IDs in a `References` / `In-Reply-To` header value. */
105
- export function messageIdList(h) {
104
+ /** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
105
+ export function normalizeMessageId(id) {
106
+ return bareMessageId(id)?.toLowerCase() ?? null;
107
+ }
108
+ /** All Message-IDs in a `References` / `In-Reply-To` header value, as written. */
109
+ export function bareMessageIdList(h) {
106
110
  if (!h)
107
111
  return [];
108
112
  const out = [];
109
113
  for (const m of h.matchAll(/<([^<>]+)>/g))
110
- out.push(m[1].trim().toLowerCase());
114
+ out.push(m[1].trim());
111
115
  if (!out.length)
112
116
  for (const t of h.split(/\s+/))
113
117
  if (t.includes("@"))
114
- out.push(t.toLowerCase());
118
+ out.push(t);
115
119
  return out;
116
120
  }
121
+ /** All Message-IDs in a `References` / `In-Reply-To` header value, lowercased (header lookups). */
122
+ export function messageIdList(h) {
123
+ return bareMessageIdList(h).map((x) => x.toLowerCase());
124
+ }
117
125
  /** Addresses that must never get an automatic reply. */
118
126
  export function isNoReplySender(email) {
119
127
  const local = email.slice(0, email.lastIndexOf("@")).toLowerCase();
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;
@@ -709,6 +728,8 @@ export declare const emailFunctions: {
709
728
  html?: string;
710
729
  truncated?: boolean;
711
730
  inReplyTo?: string;
731
+ messageIdRaw?: string;
732
+ referencesRaw?: string[];
712
733
  droppedAttachments?: number;
713
734
  auth?: {
714
735
  spf: string;
@@ -830,6 +851,7 @@ export declare const emailFunctions: {
830
851
  lastMessageAt: number;
831
852
  snippet: string;
832
853
  contactName?: string;
854
+ activityAt?: number;
833
855
  lastInboundAt?: number;
834
856
  assignee?: string;
835
857
  assigneeName?: string;
@@ -873,6 +895,8 @@ export declare const emailFunctions: {
873
895
  html?: string;
874
896
  truncated?: boolean;
875
897
  inReplyTo?: string;
898
+ messageIdRaw?: string;
899
+ referencesRaw?: string[];
876
900
  droppedAttachments?: number;
877
901
  auth?: {
878
902
  spf: string;
@@ -1322,6 +1346,15 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1322
1346
  } | null>;
1323
1347
  /** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
1324
1348
  sweep: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, number>;
1349
+ /**
1350
+ * Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
1351
+ * messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
1352
+ */
1353
+ purge: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, {
1354
+ threads: number;
1355
+ messages: number;
1356
+ more: boolean;
1357
+ }>;
1325
1358
  applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
1326
1359
  events: any[];
1327
1360
  }, ({
@@ -1406,6 +1439,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1406
1439
  html?: string;
1407
1440
  } | null;
1408
1441
  catchAll?: boolean | null;
1442
+ retention?: {
1443
+ closedDays?: number;
1444
+ spamDays?: number;
1445
+ } | null;
1409
1446
  }, GenericId<"mailboxes">>;
1410
1447
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
1411
1448
  name: string;
@@ -1424,6 +1461,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1424
1461
  html?: string;
1425
1462
  };
1426
1463
  catchAll?: boolean;
1464
+ retention?: {
1465
+ closedDays?: number;
1466
+ spamDays?: number;
1467
+ };
1427
1468
  }[]>;
1428
1469
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
1429
1470
  to: string;
@@ -1453,6 +1494,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1453
1494
  lastMessageAt: number;
1454
1495
  snippet: string;
1455
1496
  contactName?: string;
1497
+ activityAt?: number;
1456
1498
  lastInboundAt?: number;
1457
1499
  assignee?: string;
1458
1500
  assigneeName?: string;
@@ -1475,6 +1517,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1475
1517
  lastMessageAt: number;
1476
1518
  snippet: string;
1477
1519
  contactName?: string;
1520
+ activityAt?: number;
1478
1521
  lastInboundAt?: number;
1479
1522
  assignee?: string;
1480
1523
  assigneeName?: string;
@@ -1518,6 +1561,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1518
1561
  html?: string;
1519
1562
  truncated?: boolean;
1520
1563
  inReplyTo?: string;
1564
+ messageIdRaw?: string;
1565
+ referencesRaw?: string[];
1521
1566
  droppedAttachments?: number;
1522
1567
  auth?: {
1523
1568
  spf: string;
@@ -1639,6 +1684,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1639
1684
  lastMessageAt: number;
1640
1685
  snippet: string;
1641
1686
  contactName?: string;
1687
+ activityAt?: number;
1642
1688
  lastInboundAt?: number;
1643
1689
  assignee?: string;
1644
1690
  assigneeName?: string;
@@ -1682,6 +1728,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1682
1728
  html?: string;
1683
1729
  truncated?: boolean;
1684
1730
  inReplyTo?: string;
1731
+ messageIdRaw?: string;
1732
+ referencesRaw?: string[];
1685
1733
  droppedAttachments?: number;
1686
1734
  auth?: {
1687
1735
  spf: string;
package/dist/component.js CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { componentBuilders, cronJobs, defineComponent, EmberError, v } from "@freecorps/ember-server";
22
22
  import { rateLimit } from "@freecorps/ember-rate-limit";
23
- import { domainOf, isNoReplySender, parseAddress, parseAddressList, replySubject, splitPlus, subjectKey, normalizeMessageId, withPlus, } from "./address.js";
23
+ import { bareMessageId, domainOf, isNoReplySender, parseAddress, parseAddressList, replySubject, splitPlus, subjectKey, normalizeMessageId, withPlus, } from "./address.js";
24
24
  import { nextStatus, parseDeliveryEvent } from "./events.js";
25
25
  import { parseInbound, snippetOf } from "./mime.js";
26
26
  import { addressV, emailSchema, errorV } from "./schema.js";
@@ -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,
@@ -485,6 +486,8 @@ function ingest(ctx, a) {
485
486
  });
486
487
  thread = ctx.db.get(id);
487
488
  }
489
+ // The Message-ID as written, when it is the same id as the lookup one.
490
+ const idRaw = a.messageId && a.messageIdRaw?.toLowerCase() === a.messageId ? a.messageIdRaw : undefined;
488
491
  const msgId = ctx.db.insert("messages", {
489
492
  threadId: thread._id,
490
493
  mailbox: a.mailbox,
@@ -492,6 +495,8 @@ function ingest(ctx, a) {
492
495
  ...(a.messageId ? { messageIdHeader: a.messageId } : {}),
493
496
  ...(a.inReplyTo ? { inReplyTo: a.inReplyTo } : {}),
494
497
  references: a.references.slice(-100),
498
+ ...(idRaw ? { messageIdRaw: idRaw } : {}),
499
+ ...(a.referencesRaw?.length ? { referencesRaw: a.referencesRaw.slice(-100) } : {}),
495
500
  from: a.from,
496
501
  fromEmail: a.from.email,
497
502
  to: a.to,
@@ -513,6 +518,7 @@ function ingest(ctx, a) {
513
518
  const status = thread.status === "spam" ? "spam" : "open";
514
519
  ctx.db.patch(thread._id, {
515
520
  status,
521
+ activityAt: ctx.now,
516
522
  messageCount: thread.messageCount + 1,
517
523
  unreadCount: thread.unreadCount + 1,
518
524
  lastMessageAt: ctx.now,
@@ -534,7 +540,7 @@ function ingest(ctx, a) {
534
540
  subject: renderTemplateSafe(mb.autoReply.subject, data, false),
535
541
  text,
536
542
  html: mb.autoReply.html ? emailLayout(renderTemplateSafe(mb.autoReply.html, data, true)) : emailLayout(textToHtml(text)),
537
- headers: { "Auto-Submitted": "auto-replied", ...(a.messageId ? { "In-Reply-To": `<${a.messageId}>`, References: `<${a.messageId}>` } : {}) },
543
+ headers: { "Auto-Submitted": "auto-replied", ...(a.messageId ? { "In-Reply-To": `<${idRaw ?? a.messageId}>`, References: `<${idRaw ?? a.messageId}>` } : {}) },
538
544
  threadId: thread._id,
539
545
  }).id;
540
546
  }
@@ -567,6 +573,15 @@ function ingest(ctx, a) {
567
573
  }
568
574
  return { ...event, autoReplyId };
569
575
  }
576
+ /** A message's Message-ID as its sender wrote it (the lowercased one for messages stored before 0.5.9). */
577
+ function citedId(m) {
578
+ return m.messageIdRaw ?? m.messageIdHeader ?? null;
579
+ }
580
+ /** A message's References as written, when they were kept for every id (else the lowercased ones). */
581
+ function citedRefs(m) {
582
+ const raw = m.referencesRaw;
583
+ return raw && raw.length === m.references.length && raw.every((r, i) => r.toLowerCase() === m.references[i]) ? raw : m.references;
584
+ }
570
585
  function renderTemplateSafe(src, data, escape) {
571
586
  try {
572
587
  return renderTemplate(src, data, { escape });
@@ -601,10 +616,83 @@ function appendOutbound(ctx, thread, mb, a) {
601
616
  ctx.db.patch(thread._id, {
602
617
  messageCount: thread.messageCount + 1,
603
618
  lastMessageAt: ctx.now,
619
+ activityAt: ctx.now,
604
620
  ...(a.note ? {} : { status: thread.status === "spam" ? "spam" : "waiting", snippet }),
605
621
  });
606
622
  return id;
607
623
  }
624
+ const DAY_MS = 86_400_000;
625
+ const MAX_RETENTION_DAYS = 36_500;
626
+ /** Rows (messages and threads) one purge transaction deletes before it schedules the rest. */
627
+ const PURGE_BUDGET = 500;
628
+ /** Candidates one purge run looks at per mailbox and status (some may not be due yet: see `purgeExpired`). */
629
+ const PURGE_SCAN = 200;
630
+ function retentionOf(r) {
631
+ const out = {};
632
+ for (const k of ["closedDays", "spamDays"]) {
633
+ const d = r[k];
634
+ if (d === undefined)
635
+ continue;
636
+ if (!Number.isInteger(d) || d < 1 || d > MAX_RETENTION_DAYS)
637
+ fail("ValidationError", `retention.${k}: a whole number of days in 1..${MAX_RETENTION_DAYS}`);
638
+ out[k] = d;
639
+ }
640
+ return out.closedDays === undefined && out.spamDays === undefined ? undefined : out;
641
+ }
642
+ /**
643
+ * One purge pass: the closed and spam threads whose latest status change or message (`activityAt`) is older than
644
+ * their mailbox's retention, straight from the `by_mailbox_status_activity` index (every candidate is due, so a
645
+ * thread kept longer never blocks the ones behind it). Threads from before `activityAt` (absent, first in the index)
646
+ * get it from `lastMessageAt` when they are not due yet, so they leave the head of the index.
647
+ */
648
+ function purgeExpired(ctx) {
649
+ let budget = PURGE_BUDGET;
650
+ let threads = 0;
651
+ let messages = 0;
652
+ let more = false;
653
+ for (const mb of ctx.db.query("mailboxes").collect()) {
654
+ for (const [status, days] of [
655
+ ["closed", mb.retention?.closedDays],
656
+ ["spam", mb.retention?.spamDays],
657
+ ]) {
658
+ if (!days || more)
659
+ continue;
660
+ const cutoff = ctx.now - days * DAY_MS;
661
+ const due = ctx.db
662
+ .query("threads")
663
+ .withIndex("by_mailbox_status_activity", (q) => q.eq("mailbox", mb.name).eq("status", status).lte("activityAt", cutoff))
664
+ .take(PURGE_SCAN);
665
+ for (const t of due) {
666
+ if (budget <= 0) {
667
+ more = true;
668
+ break;
669
+ }
670
+ if (t.activityAt === undefined && t.lastMessageAt > cutoff) {
671
+ ctx.db.patch(t._id, { activityAt: t.lastMessageAt });
672
+ budget--;
673
+ continue;
674
+ }
675
+ const batch = ctx.db.query("messages").withIndex("by_thread", (q) => q.eq("threadId", t._id)).take(budget);
676
+ for (const m of batch)
677
+ ctx.db.delete(m._id);
678
+ messages += batch.length;
679
+ budget -= batch.length;
680
+ if (budget <= 0) {
681
+ more = true; // the thread goes on the next run, once its last messages are gone
682
+ break;
683
+ }
684
+ ctx.db.delete(t._id);
685
+ threads++;
686
+ budget--;
687
+ }
688
+ if (due.length === PURGE_SCAN)
689
+ more = true;
690
+ }
691
+ }
692
+ if (more)
693
+ ctx.scheduler.runAfter(0, "lib:purge", {});
694
+ return { threads, messages, more };
695
+ }
608
696
  // ------------------------------------------------------------------------------------------------ functions
609
697
  const pageV = v.optional(v.object({ numItems: v.number(), cursor: v.union(v.string(), v.null()) }));
610
698
  export const emailFunctions = {
@@ -721,7 +809,7 @@ export const emailFunctions = {
721
809
  .filter((q) => q.eq(q.field("emailId"), a.emailId))
722
810
  .first();
723
811
  if (out && !out.messageIdHeader)
724
- ctx.db.patch(out._id, { messageIdHeader: mid });
812
+ ctx.db.patch(out._id, { messageIdHeader: mid, messageIdRaw: bareMessageId(a.messageId) });
725
813
  }
726
814
  // Events can arrive before `finish` commits (the provider is fast): apply the ones already stored, exactly
727
815
  // as `applyEvent` would have (status, suppression, onEvent hook).
@@ -765,6 +853,14 @@ export const emailFunctions = {
765
853
  return n;
766
854
  },
767
855
  }),
856
+ /**
857
+ * Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
858
+ * messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
859
+ */
860
+ purge: internalMutation({
861
+ args: {},
862
+ handler: (ctx) => purgeExpired(ctx),
863
+ }),
768
864
  // ---------------------------------------------------------------- events
769
865
  applyEvents: internalMutation({
770
866
  args: { events: v.array(v.any()) },
@@ -852,6 +948,11 @@ export const emailFunctions = {
852
948
  autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
853
949
  /** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
854
950
  catchAll: v.optional(v.union(v.null(), v.boolean())),
951
+ /**
952
+ * Days closed and spam conversations are kept (whole days, 1..36500; a missing kind is kept forever).
953
+ * Absent: keeps the stored policy; null: clears it (kept forever).
954
+ */
955
+ retention: v.optional(v.union(v.null(), v.object({ closedDays: v.optional(v.number()), spamDays: v.optional(v.number()) }))),
855
956
  },
856
957
  handler: (ctx, a) => {
857
958
  if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
@@ -867,6 +968,7 @@ export const emailFunctions = {
867
968
  const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
868
969
  const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
869
970
  const catchAll = a.catchAll === undefined ? !!prev?.catchAll : !!a.catchAll;
971
+ const retention = a.retention === null ? undefined : a.retention === undefined ? prev?.retention : retentionOf(a.retention);
870
972
  if (catchAll) {
871
973
  const other = catchAllOf(ctx, domainOf(address));
872
974
  if (other && other.name !== a.name)
@@ -878,6 +980,7 @@ export const emailFunctions = {
878
980
  ...(displayName ? { displayName } : {}),
879
981
  ...(autoReply ? { autoReply } : {}),
880
982
  ...(catchAll ? { catchAll: true } : {}),
983
+ ...(retention ? { retention } : {}),
881
984
  nextNumber: prev?.nextNumber ?? 1,
882
985
  createdAt: prev?.createdAt ?? ctx.now,
883
986
  };
@@ -1037,7 +1140,8 @@ export const emailFunctions = {
1037
1140
  const t = ctx.db.get(a.threadId);
1038
1141
  if (!t)
1039
1142
  return fail("NotFound", "thread not found");
1040
- ctx.db.patch(a.threadId, { status: a.status });
1143
+ if (t.status !== a.status)
1144
+ ctx.db.patch(a.threadId, { status: a.status, activityAt: ctx.now });
1041
1145
  return a.status;
1042
1146
  },
1043
1147
  }),
@@ -1071,8 +1175,10 @@ export const emailFunctions = {
1071
1175
  .order("desc")
1072
1176
  .filter((q) => q.eq(q.field("direction"), "inbound"))
1073
1177
  .first();
1074
- const refs = last?.messageIdHeader ? [...last.references.slice(-20), last.messageIdHeader] : [];
1075
- const headers = refs.length ? { "In-Reply-To": `<${last.messageIdHeader}>`, References: refs.map((r) => `<${r}>`).join(" ") } : {};
1178
+ // Cited as written: Gmail threads a reply only when In-Reply-To / References match the Message-ID exactly.
1179
+ const cited = last ? citedId(last) : null;
1180
+ const refs = last && cited ? [...citedRefs(last).slice(-20), cited] : [];
1181
+ const headers = refs.length ? { "In-Reply-To": `<${cited}>`, References: refs.map((r) => `<${r}>`).join(" ") } : {};
1076
1182
  const quote = last ? `\n\n${new Date(last.at).toUTCString()}, ${last.from.name ?? last.from.email}:\n${last.text.split("\n").slice(0, 40).map((l) => `> ${l}`).join("\n")}` : "";
1077
1183
  const html = a.html ?? emailLayout(textToHtml(a.text) + (last ? `<blockquote style="margin:16px 0 0 0;padding-left:12px;border-left:3px solid #dee2e6;color:#868e96">${textToHtml(last.text.split("\n").slice(0, 40).join("\n"))}</blockquote>` : ""));
1078
1184
  const to = [{ email: thread.contact, ...(thread.contactName ? { name: thread.contactName } : {}) }];
@@ -1163,6 +1269,7 @@ export const emailFunctions = {
1163
1269
  messageCount: 0,
1164
1270
  unreadCount: 0,
1165
1271
  lastMessageAt: ctx.now,
1272
+ activityAt: ctx.now,
1166
1273
  snippet: snippetOf(a.text),
1167
1274
  });
1168
1275
  const thread = ctx.db.get(threadId);
@@ -1241,6 +1348,8 @@ async function onEmail(ctx, message) {
1241
1348
  ...(p.messageId ? { messageId: p.messageId } : {}),
1242
1349
  ...(p.inReplyTo ? { inReplyTo: p.inReplyTo } : {}),
1243
1350
  references: p.references,
1351
+ ...(p.messageIdRaw ? { messageIdRaw: p.messageIdRaw } : {}),
1352
+ ...(p.referencesRaw.length ? { referencesRaw: p.referencesRaw } : {}),
1244
1353
  from,
1245
1354
  ...(p.replyTo ? { replyTo: p.replyTo } : {}),
1246
1355
  to: p.to,
@@ -1289,7 +1398,7 @@ export const email = defineComponent({
1289
1398
  bindings: (config) => (config?.transport ?? "binding") === "binding"
1290
1399
  ? [{ type: "send_email", name: config?.binding ?? "EMAIL", ...(config?.allowedSenders?.length ? { allowed_sender_addresses: config.allowedSenders } : {}) }]
1291
1400
  : [],
1292
- crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep"),
1401
+ crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep").daily("purge", { hour: 4 }, "lib:purge"),
1293
1402
  queues: {
1294
1403
  events: { shard: (config) => config?.shard ?? "global:main", consumer: { maxBatchSize: 100, maxBatchTimeoutSeconds: 2, maxRetries: 10 }, handler: onEvents },
1295
1404
  },
package/dist/mime.d.ts CHANGED
@@ -17,6 +17,9 @@ export interface ParsedInbound {
17
17
  messageId: string | null;
18
18
  inReplyTo: string | null;
19
19
  references: string[];
20
+ /** The same Message-ID and References as written: replies cite these (Gmail compares them exactly). */
21
+ messageIdRaw: string | null;
22
+ referencesRaw: string[];
20
23
  from: EmailAddress | null;
21
24
  replyTo: EmailAddress | null;
22
25
  to: EmailAddress[];
package/dist/mime.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // header Cloudflare's MX adds (`Authentication-Results: mx.cloudflare.net; dkim=…; dmarc=…; spf=…`): only the
4
4
  // first such header is trusted, and only when its authserv-id is mx.cloudflare.net (the sender can add others).
5
5
  import PostalMime, {} from "postal-mime";
6
- import { messageIdList, normalizeMessageId } from "./address.js";
6
+ import { bareMessageId, bareMessageIdList, messageIdList, normalizeMessageId } from "./address.js";
7
7
  import { htmlToText } from "./template.js";
8
8
  const flat = (list) => {
9
9
  const out = [];
@@ -92,6 +92,8 @@ export async function parseInbound(raw, limits = {}) {
92
92
  messageId: normalizeMessageId(email.messageId),
93
93
  inReplyTo: normalizeMessageId(email.inReplyTo),
94
94
  references: messageIdList(email.references),
95
+ messageIdRaw: bareMessageId(email.messageId),
96
+ referencesRaw: bareMessageIdList(email.references),
95
97
  from,
96
98
  replyTo: flat(email.replyTo)[0] ?? null,
97
99
  to: flat(email.to),
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
  }, {
@@ -211,9 +228,13 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
211
228
  mailbox: import("@freecorps/ember-values").Validator<string, "required", never>;
212
229
  /** inbound | outbound | note */
213
230
  direction: import("@freecorps/ember-values").Validator<string, "required", never>;
231
+ /** Message-ID, In-Reply-To and References lowercased, for lookups. */
214
232
  messageIdHeader: import("@freecorps/ember-values").Validator<string, "optional", never>;
215
233
  inReplyTo: import("@freecorps/ember-values").Validator<string, "optional", never>;
216
234
  references: import("@freecorps/ember-values").Validator<string[], "required", never>;
235
+ /** Message-ID and References as written, for the headers of a reply (absent on messages stored before 0.5.9). */
236
+ messageIdRaw: import("@freecorps/ember-values").Validator<string, "optional", never>;
237
+ referencesRaw: import("@freecorps/ember-values").Validator<string[], "optional", never>;
217
238
  from: import("@freecorps/ember-values").Validator<{
218
239
  email: string;
219
240
  name?: string;
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"] }),
@@ -132,9 +143,13 @@ export const emailSchema = defineSchema({
132
143
  mailbox: v.string(),
133
144
  /** inbound | outbound | note */
134
145
  direction: v.string(),
146
+ /** Message-ID, In-Reply-To and References lowercased, for lookups. */
135
147
  messageIdHeader: v.optional(v.string()),
136
148
  inReplyTo: v.optional(v.string()),
137
149
  references: v.array(v.string()),
150
+ /** Message-ID and References as written, for the headers of a reply (absent on messages stored before 0.5.9). */
151
+ messageIdRaw: v.optional(v.string()),
152
+ referencesRaw: v.optional(v.array(v.string())),
138
153
  from: addressV,
139
154
  fromEmail: v.string(),
140
155
  to: v.array(addressV),
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.9",
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.9",
56
+ "@freecorps/ember-server": "0.5.9",
57
+ "@freecorps/ember-values": "0.5.9",
58
58
  "postal-mime": "^4.1.4"
59
59
  }
60
60
  }