@freecorps/ember-email 0.5.5 → 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 +2 -1
- package/dist/client.d.ts +11 -0
- package/dist/component.d.ts +28 -0
- package/dist/component.js +99 -0
- package/package.json +4 -4
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)
|
|
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
|
}, {
|
package/dist/component.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
56
|
-
"@freecorps/ember-server": "0.5.
|
|
57
|
-
"@freecorps/ember-values": "0.5.
|
|
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
|
}
|