@freecorps/ember-email 0.5.4 → 0.5.6

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
package/dist/client.d.ts CHANGED
@@ -155,6 +155,17 @@ export interface EmailComponentApi {
155
155
  status?: string;
156
156
  paginationOpts?: PageOpts;
157
157
  }, Page<ThreadRecord>>;
158
+ /** The mailbox's Sent view: threads it wrote in, by its latest outbound message (`lastSentAt`); `next` → `before` (opaque). */
159
+ sent: Ref<"query", {
160
+ mailbox: string;
161
+ limit?: number;
162
+ before?: string;
163
+ }, {
164
+ page: Array<ThreadRecord & {
165
+ lastSentAt: number;
166
+ }>;
167
+ next: string | null;
168
+ }>;
158
169
  thread: Ref<"query", {
159
170
  threadId: string;
160
171
  }, {
@@ -718,6 +718,20 @@ export declare const emailFunctions: {
718
718
  author?: string;
719
719
  }[];
720
720
  } | null>;
721
+ /**
722
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
723
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
724
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
725
+ * thread).
726
+ */
727
+ sent: import("@freecorps/ember-server").RegisteredQuery<"public", {
728
+ mailbox: string;
729
+ limit?: number;
730
+ before?: string;
731
+ }, {
732
+ page: any[];
733
+ next: string | null;
734
+ }>;
721
735
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
722
736
  unread: import("@freecorps/ember-server").RegisteredQuery<"public", {
723
737
  mailboxes?: string[];
@@ -1511,6 +1525,20 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1511
1525
  author?: string;
1512
1526
  }[];
1513
1527
  } | null>;
1528
+ /**
1529
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
1530
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
1531
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
1532
+ * thread).
1533
+ */
1534
+ sent: import("@freecorps/ember-server").RegisteredQuery<"public", {
1535
+ mailbox: string;
1536
+ limit?: number;
1537
+ before?: string;
1538
+ }, {
1539
+ page: any[];
1540
+ next: string | null;
1541
+ }>;
1514
1542
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
1515
1543
  unread: import("@freecorps/ember-server").RegisteredQuery<"public", {
1516
1544
  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);
@@ -874,6 +897,82 @@ export const emailFunctions = {
874
897
  return { thread, messages };
875
898
  },
876
899
  }),
900
+ /**
901
+ * The conversations the mailbox wrote in (its Sent view): threads with a reply or a new conversation sent from it,
902
+ * by its latest one, newest first. `before` (the answer's `next`, an opaque cursor) goes on to older ones. Internal
903
+ * notes and auto-replies are not sent mail here (a note is no message out; an auto-reply is no message of the
904
+ * thread).
905
+ */
906
+ sent: query({
907
+ args: { mailbox: v.string(), limit: v.optional(v.number()), before: v.optional(v.string()) },
908
+ handler: (ctx, a) => {
909
+ const mb = ctx.db.query("mailboxes").withIndex("by_name", (q) => q.eq("name", a.mailbox)).first();
910
+ if (!mb)
911
+ return { page: [], next: null };
912
+ const limit = Math.min(Math.max(Math.trunc(a.limit ?? 30), 1), 100);
913
+ // The cursor is the last message looked at: its time and id. The next page starts at that time (`lte`) and
914
+ // skips up to and including that message, in the index's order (time, then creation, then id), so messages
915
+ // sharing its millisecond are neither lost nor seen twice.
916
+ const cursor = parseSentCursor(a.before);
917
+ // Messages from the mailbox's address, newest first; a thread counts at its newest outbound message only, so a
918
+ // later page never lists it again for an older one.
919
+ const scan = limit * 4 + 20;
920
+ const fetch = (n) => ctx.db
921
+ .query("messages")
922
+ .withIndex("by_sender", (q) => {
923
+ const r = q.eq("mailbox", a.mailbox).eq("fromEmail", mb.address);
924
+ return cursor ? r.lte("at", cursor.at) : r;
925
+ })
926
+ .order("desc")
927
+ .take(n);
928
+ // Past the messages sharing the cursor's millisecond: fetched until the cursor and a full window after it are in
929
+ // hand (or there is nothing more), so a burst in one millisecond never ends the paging early.
930
+ let n = scan + (cursor ? scan : 0);
931
+ let rows = fetch(n);
932
+ let start = 0;
933
+ for (;;) {
934
+ if (cursor) {
935
+ const k = rows.findIndex((m) => m._id === cursor.id);
936
+ // The cursor's message is gone (deleted): start after every message of its millisecond.
937
+ start = k >= 0 ? k + 1 : rows.findIndex((m) => m.at < cursor.at);
938
+ if (start < 0)
939
+ start = rows.length;
940
+ }
941
+ if (rows.length < n || rows.length - start >= scan || n >= SENT_MAX_FETCH)
942
+ break;
943
+ n = Math.min(n * 2, SENT_MAX_FETCH);
944
+ rows = fetch(n);
945
+ }
946
+ const window = rows.slice(start, start + scan);
947
+ const page = [];
948
+ const seen = new Set();
949
+ let next = null;
950
+ for (const m of window) {
951
+ if (m.direction !== "outbound" || seen.has(m.threadId))
952
+ continue;
953
+ seen.add(m.threadId);
954
+ const newer = ctx.db
955
+ .query("messages")
956
+ .withIndex("by_thread", (q) => q.eq("threadId", m.threadId).gte("at", m.at))
957
+ .collect()
958
+ .some((x) => x._id !== m._id && x.direction === "outbound" && x.fromEmail === mb.address && isAfter(x, m));
959
+ if (newer)
960
+ continue;
961
+ const thread = ctx.db.get(m.threadId);
962
+ if (!thread)
963
+ continue;
964
+ page.push({ ...thread, lastSentAt: m.at });
965
+ if (page.length === limit) {
966
+ next = sentCursor(m);
967
+ break;
968
+ }
969
+ }
970
+ // A full window that did not fill the page: older messages may still hold sent threads.
971
+ if (next === null && window.length === scan)
972
+ next = sentCursor(window[window.length - 1]);
973
+ return { page, next };
974
+ },
975
+ }),
877
976
  /** Unread messages and thread counts per mailbox (aggregate: O(mailboxes)). */
878
977
  unread: query({
879
978
  args: { mailboxes: v.optional(v.array(v.string())) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecorps/ember-email",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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.4",
56
- "@freecorps/ember-server": "0.5.4",
57
- "@freecorps/ember-values": "0.5.4",
55
+ "@freecorps/ember-rate-limit": "0.5.6",
56
+ "@freecorps/ember-server": "0.5.6",
57
+ "@freecorps/ember-values": "0.5.6",
58
58
  "postal-mime": "^4.1.4"
59
59
  }
60
60
  }