@freecorps/ember-email 0.5.5 → 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
@@ -64,7 +64,8 @@ export const signup = mutation({
64
64
  - Your `onInbound` hook runs in the same transaction (tickets, push).
65
65
  - **Inbox queries** for dashboards and mobile apps: `threads`, `thread`, `unread` (aggregate), `search`
66
66
  (`searchIndex`), `reply`, `note`, `markRead`, `markUnread`, `setStatus`, `assign`, `compose` (a new conversation
67
- from the mailbox, answers join it as above) and `recent` (latest threads with their messages, for offline replicas).
67
+ from the mailbox, answers join it as above), `recent` (latest threads with their messages, for offline replicas) and
68
+ `sent` (the mailbox's Sent view: threads it wrote in, by its latest send, paged with `before`).
68
69
  `reply` and `compose` take `attachments: [{ filename, contentType, file }]`, files already stored in the shard
69
70
  (uploaded with `ctx.storage.generateUploadUrl`). They go with the email and stay on the thread's outbound message
70
71
  (with their size). Mind the provider's message size: for a large file, put a download link in the text
@@ -96,6 +97,10 @@ What it does:
96
97
  routing subdomain, so removal is a dashboard step.
97
98
  - **Mailboxes:** it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule
98
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.
99
104
  - **Events:** it adds an `email.sending` event subscription → the component's queue.
100
105
 
101
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;
@@ -155,6 +157,17 @@ export interface EmailComponentApi {
155
157
  status?: string;
156
158
  paginationOpts?: PageOpts;
157
159
  }, Page<ThreadRecord>>;
160
+ /** The mailbox's Sent view: threads it wrote in, by its latest outbound message (`lastSentAt`); `next` → `before` (opaque). */
161
+ sent: Ref<"query", {
162
+ mailbox: string;
163
+ limit?: number;
164
+ before?: string;
165
+ }, {
166
+ page: Array<ThreadRecord & {
167
+ lastSentAt: number;
168
+ }>;
169
+ next: string | null;
170
+ }>;
158
171
  thread: Ref<"query", {
159
172
  threadId: string;
160
173
  }, {
@@ -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;
@@ -718,6 +720,20 @@ export declare const emailFunctions: {
718
720
  author?: string;
719
721
  }[];
720
722
  } | null>;
723
+ /**
724
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
725
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
726
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
727
+ * thread).
728
+ */
729
+ sent: import("@freecorps/ember-server").RegisteredQuery<"public", {
730
+ mailbox: string;
731
+ limit?: number;
732
+ before?: string;
733
+ }, {
734
+ page: any[];
735
+ next: string | null;
736
+ }>;
721
737
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
722
738
  unread: import("@freecorps/ember-server").RegisteredQuery<"public", {
723
739
  mailboxes?: string[];
@@ -1389,6 +1405,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1389
1405
  subject: string;
1390
1406
  html?: string;
1391
1407
  } | null;
1408
+ catchAll?: boolean | null;
1392
1409
  }, GenericId<"mailboxes">>;
1393
1410
  removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
1394
1411
  name: string;
@@ -1406,6 +1423,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1406
1423
  subject: string;
1407
1424
  html?: string;
1408
1425
  };
1426
+ catchAll?: boolean;
1409
1427
  }[]>;
1410
1428
  route: import("@freecorps/ember-server").RegisteredQuery<"public", {
1411
1429
  to: string;
@@ -1511,6 +1529,20 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1511
1529
  author?: string;
1512
1530
  }[];
1513
1531
  } | null>;
1532
+ /**
1533
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
1534
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
1535
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
1536
+ * thread).
1537
+ */
1538
+ sent: import("@freecorps/ember-server").RegisteredQuery<"public", {
1539
+ mailbox: string;
1540
+ limit?: number;
1541
+ before?: string;
1542
+ }, {
1543
+ page: any[];
1544
+ next: string | null;
1545
+ }>;
1514
1546
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
1515
1547
  unread: import("@freecorps/ember-server").RegisteredQuery<"public", {
1516
1548
  mailboxes?: string[];
package/dist/component.js CHANGED
@@ -85,6 +85,29 @@ const threadReplyTo = (ctx, mb, thread) => (cfg(ctx).replyToken ? { replyTo: wit
85
85
  /** The MIME attachments of the email: every file not sent as a link. */
86
86
  const mimeParts = (list) => (list ?? []).filter((a) => !a.link).map(({ filename, contentType, file }) => ({ filename, contentType, file }));
87
87
  /** The stored message's attachment rows (size from the file's metadata when known). */
88
+ /** The most messages one Sent page reads past its cursor's millisecond (no mailbox sends this many in one ms). */
89
+ const SENT_MAX_FETCH = 10_000;
90
+ /** The Sent view's cursor: a message's time and id (`<at>:<id>`), opaque to callers. */
91
+ function sentCursor(m) {
92
+ return `${m.at}:${m._id}`;
93
+ }
94
+ function parseSentCursor(raw) {
95
+ if (raw === undefined)
96
+ return null;
97
+ const k = raw.indexOf(":");
98
+ const at = Number(raw.slice(0, k));
99
+ if (k <= 0 || !Number.isFinite(at) || k === raw.length - 1)
100
+ fail("ValidationError", "before: not a cursor this query gave");
101
+ return { at, id: raw.slice(k + 1) };
102
+ }
103
+ /** Whether message `x` comes after `m` in the by_sender order (time, then creation, then id). */
104
+ function isAfter(x, m) {
105
+ if (x.at !== m.at)
106
+ return x.at > m.at;
107
+ if (x._creationTime !== m._creationTime)
108
+ return x._creationTime > m._creationTime;
109
+ return x._id > m._id;
110
+ }
88
111
  function attachmentRows(ctx, list) {
89
112
  return (list ?? []).map((a) => {
90
113
  const meta = ctx.storage?.getMetadata?.(a.file);
@@ -364,10 +387,18 @@ function applyToEmail(ctx, e, ev) {
364
387
  }
365
388
  // ------------------------------------------------------------------------------------------------ inbound
366
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
+ }
367
394
  function routeOf(ctx, to) {
368
395
  const { base, tag } = splitPlus(to.toLowerCase());
369
396
  const m = ctx.db.query("mailboxes").withIndex("by_address", (q) => q.eq("address", base)).first();
370
- 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;
371
402
  }
372
403
  function token(ctx) {
373
404
  const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -819,6 +850,8 @@ export const emailFunctions = {
819
850
  displayName: v.optional(v.union(v.null(), v.string())),
820
851
  /** Absent: keeps the stored one; null: clears it. */
821
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())),
822
855
  },
823
856
  handler: (ctx, a) => {
824
857
  if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
@@ -833,7 +866,21 @@ export const emailFunctions = {
833
866
  // Patch semantics: an absent field keeps its stored value, null clears it.
834
867
  const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
835
868
  const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
836
- 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
+ };
837
884
  if (prev) {
838
885
  ctx.db.replace(prev._id, doc);
839
886
  return prev._id;
@@ -874,6 +921,82 @@ export const emailFunctions = {
874
921
  return { thread, messages };
875
922
  },
876
923
  }),
924
+ /**
925
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
926
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
927
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
928
+ * thread).
929
+ */
930
+ sent: query({
931
+ args: { mailbox: v.string(), limit: v.optional(v.number()), before: v.optional(v.string()) },
932
+ handler: (ctx, a) => {
933
+ const mb = ctx.db.query("mailboxes").withIndex("by_name", (q) => q.eq("name", a.mailbox)).first();
934
+ if (!mb)
935
+ return { page: [], next: null };
936
+ const limit = Math.min(Math.max(Math.trunc(a.limit ?? 30), 1), 100);
937
+ // The cursor is the last message looked at: its time and id. The next page starts at that time (`lte`) and
938
+ // skips up to and including that message, in the index's order (time, then creation, then id), so messages
939
+ // sharing its millisecond are neither lost nor seen twice.
940
+ const cursor = parseSentCursor(a.before);
941
+ // Messages from the mailbox's address, newest first; a thread counts at its newest outbound message only, so a
942
+ // later page never lists it again for an older one.
943
+ const scan = limit * 4 + 20;
944
+ const fetch = (n) => ctx.db
945
+ .query("messages")
946
+ .withIndex("by_sender", (q) => {
947
+ const r = q.eq("mailbox", a.mailbox).eq("fromEmail", mb.address);
948
+ return cursor ? r.lte("at", cursor.at) : r;
949
+ })
950
+ .order("desc")
951
+ .take(n);
952
+ // Past the messages sharing the cursor's millisecond: fetched until the cursor and a full window after it are in
953
+ // hand (or there is nothing more), so a burst in one millisecond never ends the paging early.
954
+ let n = scan + (cursor ? scan : 0);
955
+ let rows = fetch(n);
956
+ let start = 0;
957
+ for (;;) {
958
+ if (cursor) {
959
+ const k = rows.findIndex((m) => m._id === cursor.id);
960
+ // The cursor's message is gone (deleted): start after every message of its millisecond.
961
+ start = k >= 0 ? k + 1 : rows.findIndex((m) => m.at < cursor.at);
962
+ if (start < 0)
963
+ start = rows.length;
964
+ }
965
+ if (rows.length < n || rows.length - start >= scan || n >= SENT_MAX_FETCH)
966
+ break;
967
+ n = Math.min(n * 2, SENT_MAX_FETCH);
968
+ rows = fetch(n);
969
+ }
970
+ const window = rows.slice(start, start + scan);
971
+ const page = [];
972
+ const seen = new Set();
973
+ let next = null;
974
+ for (const m of window) {
975
+ if (m.direction !== "outbound" || seen.has(m.threadId))
976
+ continue;
977
+ seen.add(m.threadId);
978
+ const newer = ctx.db
979
+ .query("messages")
980
+ .withIndex("by_thread", (q) => q.eq("threadId", m.threadId).gte("at", m.at))
981
+ .collect()
982
+ .some((x) => x._id !== m._id && x.direction === "outbound" && x.fromEmail === mb.address && isAfter(x, m));
983
+ if (newer)
984
+ continue;
985
+ const thread = ctx.db.get(m.threadId);
986
+ if (!thread)
987
+ continue;
988
+ page.push({ ...thread, lastSentAt: m.at });
989
+ if (page.length === limit) {
990
+ next = sentCursor(m);
991
+ break;
992
+ }
993
+ }
994
+ // A full window that did not fill the page: older messages may still hold sent threads.
995
+ if (next === null && window.length === scan)
996
+ next = sentCursor(window[window.length - 1]);
997
+ return { page, next };
998
+ },
999
+ }),
877
1000
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
878
1001
  unread: query({
879
1002
  args: { mailboxes: v.optional(v.array(v.string())) },
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.5",
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.5",
56
- "@freecorps/ember-server": "0.5.5",
57
- "@freecorps/ember-values": "0.5.5",
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
  }