@freecorps/ember-email 0.5.6 → 0.5.8
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 +8 -0
- package/dist/client.d.ts +9 -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 +44 -0
- package/dist/component.js +120 -4
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +13 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -71,6 +71,10 @@ export const signup = mutation({
|
|
|
71
71
|
(with their size). Mind the provider's message size: for a large file, put a download link in the text
|
|
72
72
|
(`ctx.storage.getUrl(file, { download, expiresIn })`) and pass it with `link: true`. The email then has no MIME part
|
|
73
73
|
for it, and the message still holds the file (the thread lists it, and it is not collected).
|
|
74
|
+
- **Retention:** `upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })` keeps closed and spam conversations
|
|
75
|
+
that many days, counted from the latest status change or message; a daily job (`lib:purge`, 04:00
|
|
76
|
+
UTC) then deletes the conversation and its messages, and file GC deletes their `.eml` and attachments. Without
|
|
77
|
+
`retention` (or with `retention: null`) nothing is deleted.
|
|
74
78
|
|
|
75
79
|
## Domains: `@freecorps/ember-email/cloudflare`
|
|
76
80
|
|
|
@@ -97,6 +101,10 @@ What it does:
|
|
|
97
101
|
routing subdomain, so removal is a dashboard step.
|
|
98
102
|
- **Mailboxes:** it adds one literal rule per mailbox address on the domain → your Worker. It never changes a rule
|
|
99
103
|
that routes somewhere else.
|
|
104
|
+
- **Catch-all:** with `catchAll: true` (a mailbox of the domain is its catch-all, `upsertMailbox({ catchAll: true })`),
|
|
105
|
+
the zone's catch-all rule → your Worker, so every address no mailbox has reaches that mailbox, with the address it
|
|
106
|
+
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
|
|
107
|
+
that routes somewhere else. `destroyDomain`, or a sync without `catchAll`, turns it back off.
|
|
100
108
|
- **Events:** it adds an `email.sending` event subscription → the component's queue.
|
|
101
109
|
|
|
102
110
|
## Offline replicas: `@freecorps/ember-email/schema`
|
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;
|
|
@@ -133,6 +138,8 @@ export interface EmailComponentApi {
|
|
|
133
138
|
text: string;
|
|
134
139
|
html?: string;
|
|
135
140
|
} | null;
|
|
141
|
+
catchAll?: boolean | null;
|
|
142
|
+
retention?: MailboxRetention | null;
|
|
136
143
|
}, string>;
|
|
137
144
|
removeMailbox: Ref<"mutation", {
|
|
138
145
|
name: string;
|
|
@@ -143,6 +150,8 @@ export interface EmailComponentApi {
|
|
|
143
150
|
address: string;
|
|
144
151
|
displayName?: string;
|
|
145
152
|
autoReply?: unknown;
|
|
153
|
+
catchAll?: boolean;
|
|
154
|
+
retention?: MailboxRetention;
|
|
146
155
|
}>>;
|
|
147
156
|
route: Ref<"query", {
|
|
148
157
|
to: string;
|
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
|
@@ -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
|
}, ({
|
|
@@ -596,6 +605,11 @@ export declare const emailFunctions: {
|
|
|
596
605
|
subject: string;
|
|
597
606
|
html?: string;
|
|
598
607
|
} | null;
|
|
608
|
+
catchAll?: boolean | null;
|
|
609
|
+
retention?: {
|
|
610
|
+
closedDays?: number;
|
|
611
|
+
spamDays?: number;
|
|
612
|
+
} | null;
|
|
599
613
|
}, GenericId<"mailboxes">>;
|
|
600
614
|
removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
601
615
|
name: string;
|
|
@@ -613,6 +627,11 @@ export declare const emailFunctions: {
|
|
|
613
627
|
subject: string;
|
|
614
628
|
html?: string;
|
|
615
629
|
};
|
|
630
|
+
catchAll?: boolean;
|
|
631
|
+
retention?: {
|
|
632
|
+
closedDays?: number;
|
|
633
|
+
spamDays?: number;
|
|
634
|
+
};
|
|
616
635
|
}[]>;
|
|
617
636
|
route: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
618
637
|
to: string;
|
|
@@ -642,6 +661,7 @@ export declare const emailFunctions: {
|
|
|
642
661
|
lastMessageAt: number;
|
|
643
662
|
snippet: string;
|
|
644
663
|
contactName?: string;
|
|
664
|
+
activityAt?: number;
|
|
645
665
|
lastInboundAt?: number;
|
|
646
666
|
assignee?: string;
|
|
647
667
|
assigneeName?: string;
|
|
@@ -664,6 +684,7 @@ export declare const emailFunctions: {
|
|
|
664
684
|
lastMessageAt: number;
|
|
665
685
|
snippet: string;
|
|
666
686
|
contactName?: string;
|
|
687
|
+
activityAt?: number;
|
|
667
688
|
lastInboundAt?: number;
|
|
668
689
|
assignee?: string;
|
|
669
690
|
assigneeName?: string;
|
|
@@ -828,6 +849,7 @@ export declare const emailFunctions: {
|
|
|
828
849
|
lastMessageAt: number;
|
|
829
850
|
snippet: string;
|
|
830
851
|
contactName?: string;
|
|
852
|
+
activityAt?: number;
|
|
831
853
|
lastInboundAt?: number;
|
|
832
854
|
assignee?: string;
|
|
833
855
|
assigneeName?: string;
|
|
@@ -1320,6 +1342,15 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1320
1342
|
} | null>;
|
|
1321
1343
|
/** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
|
|
1322
1344
|
sweep: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, number>;
|
|
1345
|
+
/**
|
|
1346
|
+
* Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
|
|
1347
|
+
* messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
|
|
1348
|
+
*/
|
|
1349
|
+
purge: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, {
|
|
1350
|
+
threads: number;
|
|
1351
|
+
messages: number;
|
|
1352
|
+
more: boolean;
|
|
1353
|
+
}>;
|
|
1323
1354
|
applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
|
|
1324
1355
|
events: any[];
|
|
1325
1356
|
}, ({
|
|
@@ -1403,6 +1434,11 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1403
1434
|
subject: string;
|
|
1404
1435
|
html?: string;
|
|
1405
1436
|
} | null;
|
|
1437
|
+
catchAll?: boolean | null;
|
|
1438
|
+
retention?: {
|
|
1439
|
+
closedDays?: number;
|
|
1440
|
+
spamDays?: number;
|
|
1441
|
+
} | null;
|
|
1406
1442
|
}, GenericId<"mailboxes">>;
|
|
1407
1443
|
removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
1408
1444
|
name: string;
|
|
@@ -1420,6 +1456,11 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1420
1456
|
subject: string;
|
|
1421
1457
|
html?: string;
|
|
1422
1458
|
};
|
|
1459
|
+
catchAll?: boolean;
|
|
1460
|
+
retention?: {
|
|
1461
|
+
closedDays?: number;
|
|
1462
|
+
spamDays?: number;
|
|
1463
|
+
};
|
|
1423
1464
|
}[]>;
|
|
1424
1465
|
route: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
1425
1466
|
to: string;
|
|
@@ -1449,6 +1490,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1449
1490
|
lastMessageAt: number;
|
|
1450
1491
|
snippet: string;
|
|
1451
1492
|
contactName?: string;
|
|
1493
|
+
activityAt?: number;
|
|
1452
1494
|
lastInboundAt?: number;
|
|
1453
1495
|
assignee?: string;
|
|
1454
1496
|
assigneeName?: string;
|
|
@@ -1471,6 +1513,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1471
1513
|
lastMessageAt: number;
|
|
1472
1514
|
snippet: string;
|
|
1473
1515
|
contactName?: string;
|
|
1516
|
+
activityAt?: number;
|
|
1474
1517
|
lastInboundAt?: number;
|
|
1475
1518
|
assignee?: string;
|
|
1476
1519
|
assigneeName?: string;
|
|
@@ -1635,6 +1678,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1635
1678
|
lastMessageAt: number;
|
|
1636
1679
|
snippet: string;
|
|
1637
1680
|
contactName?: string;
|
|
1681
|
+
activityAt?: number;
|
|
1638
1682
|
lastInboundAt?: number;
|
|
1639
1683
|
assignee?: string;
|
|
1640
1684
|
assigneeName?: string;
|
package/dist/component.js
CHANGED
|
@@ -387,10 +387,18 @@ function applyToEmail(ctx, e, ev) {
|
|
|
387
387
|
}
|
|
388
388
|
// ------------------------------------------------------------------------------------------------ inbound
|
|
389
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
|
+
}
|
|
390
394
|
function routeOf(ctx, to) {
|
|
391
395
|
const { base, tag } = splitPlus(to.toLowerCase());
|
|
392
396
|
const m = ctx.db.query("mailboxes").withIndex("by_address", (q) => q.eq("address", base)).first();
|
|
393
|
-
|
|
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;
|
|
394
402
|
}
|
|
395
403
|
function token(ctx) {
|
|
396
404
|
const abc = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
@@ -469,6 +477,7 @@ function ingest(ctx, a) {
|
|
|
469
477
|
contact,
|
|
470
478
|
...((a.replyTo ?? a.from).name ? { contactName: (a.replyTo ?? a.from).name } : {}),
|
|
471
479
|
status: spam ? "spam" : "open",
|
|
480
|
+
activityAt: ctx.now,
|
|
472
481
|
token: token(ctx),
|
|
473
482
|
messageCount: 0,
|
|
474
483
|
unreadCount: 0,
|
|
@@ -505,6 +514,7 @@ function ingest(ctx, a) {
|
|
|
505
514
|
const status = thread.status === "spam" ? "spam" : "open";
|
|
506
515
|
ctx.db.patch(thread._id, {
|
|
507
516
|
status,
|
|
517
|
+
activityAt: ctx.now,
|
|
508
518
|
messageCount: thread.messageCount + 1,
|
|
509
519
|
unreadCount: thread.unreadCount + 1,
|
|
510
520
|
lastMessageAt: ctx.now,
|
|
@@ -593,10 +603,83 @@ function appendOutbound(ctx, thread, mb, a) {
|
|
|
593
603
|
ctx.db.patch(thread._id, {
|
|
594
604
|
messageCount: thread.messageCount + 1,
|
|
595
605
|
lastMessageAt: ctx.now,
|
|
606
|
+
activityAt: ctx.now,
|
|
596
607
|
...(a.note ? {} : { status: thread.status === "spam" ? "spam" : "waiting", snippet }),
|
|
597
608
|
});
|
|
598
609
|
return id;
|
|
599
610
|
}
|
|
611
|
+
const DAY_MS = 86_400_000;
|
|
612
|
+
const MAX_RETENTION_DAYS = 36_500;
|
|
613
|
+
/** Rows (messages and threads) one purge transaction deletes before it schedules the rest. */
|
|
614
|
+
const PURGE_BUDGET = 500;
|
|
615
|
+
/** Candidates one purge run looks at per mailbox and status (some may not be due yet: see `purgeExpired`). */
|
|
616
|
+
const PURGE_SCAN = 200;
|
|
617
|
+
function retentionOf(r) {
|
|
618
|
+
const out = {};
|
|
619
|
+
for (const k of ["closedDays", "spamDays"]) {
|
|
620
|
+
const d = r[k];
|
|
621
|
+
if (d === undefined)
|
|
622
|
+
continue;
|
|
623
|
+
if (!Number.isInteger(d) || d < 1 || d > MAX_RETENTION_DAYS)
|
|
624
|
+
fail("ValidationError", `retention.${k}: a whole number of days in 1..${MAX_RETENTION_DAYS}`);
|
|
625
|
+
out[k] = d;
|
|
626
|
+
}
|
|
627
|
+
return out.closedDays === undefined && out.spamDays === undefined ? undefined : out;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* One purge pass: the closed and spam threads whose latest status change or message (`activityAt`) is older than
|
|
631
|
+
* their mailbox's retention, straight from the `by_mailbox_status_activity` index (every candidate is due, so a
|
|
632
|
+
* thread kept longer never blocks the ones behind it). Threads from before `activityAt` (absent, first in the index)
|
|
633
|
+
* get it from `lastMessageAt` when they are not due yet, so they leave the head of the index.
|
|
634
|
+
*/
|
|
635
|
+
function purgeExpired(ctx) {
|
|
636
|
+
let budget = PURGE_BUDGET;
|
|
637
|
+
let threads = 0;
|
|
638
|
+
let messages = 0;
|
|
639
|
+
let more = false;
|
|
640
|
+
for (const mb of ctx.db.query("mailboxes").collect()) {
|
|
641
|
+
for (const [status, days] of [
|
|
642
|
+
["closed", mb.retention?.closedDays],
|
|
643
|
+
["spam", mb.retention?.spamDays],
|
|
644
|
+
]) {
|
|
645
|
+
if (!days || more)
|
|
646
|
+
continue;
|
|
647
|
+
const cutoff = ctx.now - days * DAY_MS;
|
|
648
|
+
const due = ctx.db
|
|
649
|
+
.query("threads")
|
|
650
|
+
.withIndex("by_mailbox_status_activity", (q) => q.eq("mailbox", mb.name).eq("status", status).lte("activityAt", cutoff))
|
|
651
|
+
.take(PURGE_SCAN);
|
|
652
|
+
for (const t of due) {
|
|
653
|
+
if (budget <= 0) {
|
|
654
|
+
more = true;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
if (t.activityAt === undefined && t.lastMessageAt > cutoff) {
|
|
658
|
+
ctx.db.patch(t._id, { activityAt: t.lastMessageAt });
|
|
659
|
+
budget--;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
const batch = ctx.db.query("messages").withIndex("by_thread", (q) => q.eq("threadId", t._id)).take(budget);
|
|
663
|
+
for (const m of batch)
|
|
664
|
+
ctx.db.delete(m._id);
|
|
665
|
+
messages += batch.length;
|
|
666
|
+
budget -= batch.length;
|
|
667
|
+
if (budget <= 0) {
|
|
668
|
+
more = true; // the thread goes on the next run, once its last messages are gone
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
ctx.db.delete(t._id);
|
|
672
|
+
threads++;
|
|
673
|
+
budget--;
|
|
674
|
+
}
|
|
675
|
+
if (due.length === PURGE_SCAN)
|
|
676
|
+
more = true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (more)
|
|
680
|
+
ctx.scheduler.runAfter(0, "lib:purge", {});
|
|
681
|
+
return { threads, messages, more };
|
|
682
|
+
}
|
|
600
683
|
// ------------------------------------------------------------------------------------------------ functions
|
|
601
684
|
const pageV = v.optional(v.object({ numItems: v.number(), cursor: v.union(v.string(), v.null()) }));
|
|
602
685
|
export const emailFunctions = {
|
|
@@ -757,6 +840,14 @@ export const emailFunctions = {
|
|
|
757
840
|
return n;
|
|
758
841
|
},
|
|
759
842
|
}),
|
|
843
|
+
/**
|
|
844
|
+
* Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
|
|
845
|
+
* messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
|
|
846
|
+
*/
|
|
847
|
+
purge: internalMutation({
|
|
848
|
+
args: {},
|
|
849
|
+
handler: (ctx) => purgeExpired(ctx),
|
|
850
|
+
}),
|
|
760
851
|
// ---------------------------------------------------------------- events
|
|
761
852
|
applyEvents: internalMutation({
|
|
762
853
|
args: { events: v.array(v.any()) },
|
|
@@ -842,6 +933,13 @@ export const emailFunctions = {
|
|
|
842
933
|
displayName: v.optional(v.union(v.null(), v.string())),
|
|
843
934
|
/** Absent: keeps the stored one; null: clears it. */
|
|
844
935
|
autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
|
|
936
|
+
/** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
|
|
937
|
+
catchAll: v.optional(v.union(v.null(), v.boolean())),
|
|
938
|
+
/**
|
|
939
|
+
* Days closed and spam conversations are kept (whole days, 1..36500; a missing kind is kept forever).
|
|
940
|
+
* Absent: keeps the stored policy; null: clears it (kept forever).
|
|
941
|
+
*/
|
|
942
|
+
retention: v.optional(v.union(v.null(), v.object({ closedDays: v.optional(v.number()), spamDays: v.optional(v.number()) }))),
|
|
845
943
|
},
|
|
846
944
|
handler: (ctx, a) => {
|
|
847
945
|
if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
|
|
@@ -856,7 +954,23 @@ export const emailFunctions = {
|
|
|
856
954
|
// Patch semantics: an absent field keeps its stored value, null clears it.
|
|
857
955
|
const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
|
|
858
956
|
const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
|
|
859
|
-
const
|
|
957
|
+
const catchAll = a.catchAll === undefined ? !!prev?.catchAll : !!a.catchAll;
|
|
958
|
+
const retention = a.retention === null ? undefined : a.retention === undefined ? prev?.retention : retentionOf(a.retention);
|
|
959
|
+
if (catchAll) {
|
|
960
|
+
const other = catchAllOf(ctx, domainOf(address));
|
|
961
|
+
if (other && other.name !== a.name)
|
|
962
|
+
fail("ValidationError", `${domainOf(address)} already has a catch-all: mailbox ${other.name}`);
|
|
963
|
+
}
|
|
964
|
+
const doc = {
|
|
965
|
+
name: a.name,
|
|
966
|
+
address,
|
|
967
|
+
...(displayName ? { displayName } : {}),
|
|
968
|
+
...(autoReply ? { autoReply } : {}),
|
|
969
|
+
...(catchAll ? { catchAll: true } : {}),
|
|
970
|
+
...(retention ? { retention } : {}),
|
|
971
|
+
nextNumber: prev?.nextNumber ?? 1,
|
|
972
|
+
createdAt: prev?.createdAt ?? ctx.now,
|
|
973
|
+
};
|
|
860
974
|
if (prev) {
|
|
861
975
|
ctx.db.replace(prev._id, doc);
|
|
862
976
|
return prev._id;
|
|
@@ -1013,7 +1127,8 @@ export const emailFunctions = {
|
|
|
1013
1127
|
const t = ctx.db.get(a.threadId);
|
|
1014
1128
|
if (!t)
|
|
1015
1129
|
return fail("NotFound", "thread not found");
|
|
1016
|
-
|
|
1130
|
+
if (t.status !== a.status)
|
|
1131
|
+
ctx.db.patch(a.threadId, { status: a.status, activityAt: ctx.now });
|
|
1017
1132
|
return a.status;
|
|
1018
1133
|
},
|
|
1019
1134
|
}),
|
|
@@ -1139,6 +1254,7 @@ export const emailFunctions = {
|
|
|
1139
1254
|
messageCount: 0,
|
|
1140
1255
|
unreadCount: 0,
|
|
1141
1256
|
lastMessageAt: ctx.now,
|
|
1257
|
+
activityAt: ctx.now,
|
|
1142
1258
|
snippet: snippetOf(a.text),
|
|
1143
1259
|
});
|
|
1144
1260
|
const thread = ctx.db.get(threadId);
|
|
@@ -1265,7 +1381,7 @@ export const email = defineComponent({
|
|
|
1265
1381
|
bindings: (config) => (config?.transport ?? "binding") === "binding"
|
|
1266
1382
|
? [{ type: "send_email", name: config?.binding ?? "EMAIL", ...(config?.allowedSenders?.length ? { allowed_sender_addresses: config.allowedSenders } : {}) }]
|
|
1267
1383
|
: [],
|
|
1268
|
-
crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep"),
|
|
1384
|
+
crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep").daily("purge", { hour: 4 }, "lib:purge"),
|
|
1269
1385
|
queues: {
|
|
1270
1386
|
events: { shard: (config) => config?.shard ?? "global:main", consumer: { maxBatchSize: 100, maxBatchTimeoutSeconds: 2, maxRetries: 10 }, handler: onEvents },
|
|
1271
1387
|
},
|
package/dist/schema.d.ts
CHANGED
|
@@ -169,6 +169,19 @@ 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>;
|
|
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>>;
|
|
172
185
|
nextNumber: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
173
186
|
createdAt: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
174
187
|
}>, {
|
|
@@ -185,6 +198,11 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
185
198
|
contactName: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
186
199
|
/** open | waiting | closed | spam */
|
|
187
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>;
|
|
188
206
|
/** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
|
|
189
207
|
token: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
190
208
|
messageCount: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
@@ -198,6 +216,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
198
216
|
}>, {
|
|
199
217
|
by_mailbox_last: ["mailbox", "lastMessageAt", "_creationTime"];
|
|
200
218
|
by_mailbox_status: ["mailbox", "status", "lastMessageAt", "_creationTime"];
|
|
219
|
+
by_mailbox_status_activity: ["mailbox", "status", "activityAt", "_creationTime"];
|
|
201
220
|
by_token: ["token", "_creationTime"];
|
|
202
221
|
by_contact_subject: ["mailbox", "contact", "subjectKey", "_creationTime"];
|
|
203
222
|
}, {
|
package/dist/schema.js
CHANGED
|
@@ -93,6 +93,13 @@ 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()),
|
|
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()) })),
|
|
96
103
|
nextNumber: v.number(),
|
|
97
104
|
createdAt: v.number(),
|
|
98
105
|
})
|
|
@@ -108,6 +115,11 @@ export const emailSchema = defineSchema({
|
|
|
108
115
|
contactName: v.optional(v.string()),
|
|
109
116
|
/** open | waiting | closed | spam */
|
|
110
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()),
|
|
111
123
|
/** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
|
|
112
124
|
token: v.string(),
|
|
113
125
|
messageCount: v.number(),
|
|
@@ -121,6 +133,7 @@ export const emailSchema = defineSchema({
|
|
|
121
133
|
})
|
|
122
134
|
.index("by_mailbox_last", ["mailbox", "lastMessageAt"])
|
|
123
135
|
.index("by_mailbox_status", ["mailbox", "status", "lastMessageAt"])
|
|
136
|
+
.index("by_mailbox_status_activity", ["mailbox", "status", "activityAt"])
|
|
124
137
|
.index("by_token", ["token"])
|
|
125
138
|
.index("by_contact_subject", ["mailbox", "contact", "subjectKey"])
|
|
126
139
|
.aggregate("unread", { by: ["mailbox"], count: true, sum: ["unreadCount"] }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-email",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
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.8",
|
|
56
|
+
"@freecorps/ember-server": "0.5.8",
|
|
57
|
+
"@freecorps/ember-values": "0.5.8",
|
|
58
58
|
"postal-mime": "^4.1.4"
|
|
59
59
|
}
|
|
60
60
|
}
|