@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 +6 -1
- package/dist/client.d.ts +13 -0
- package/dist/cloudflare/api.d.ts +9 -0
- package/dist/cloudflare/api.js +22 -0
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/onboard.d.ts +7 -0
- package/dist/cloudflare/onboard.js +26 -1
- package/dist/component.d.ts +32 -0
- package/dist/component.js +125 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -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
|
|
@@ -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
|
}, {
|
package/dist/cloudflare/api.d.ts
CHANGED
|
@@ -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<{
|
package/dist/cloudflare/api.js
CHANGED
|
@@ -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
|
package/dist/cloudflare/index.js
CHANGED
|
@@ -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));
|
package/dist/component.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
56
|
-
"@freecorps/ember-server": "0.5.
|
|
57
|
-
"@freecorps/ember-values": "0.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
|
}
|