@freecorps/ember-email 0.5.7 → 0.5.9
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 -0
- package/dist/address.d.ts +5 -1
- package/dist/address.js +15 -7
- package/dist/client.d.ts +7 -0
- package/dist/component.d.ts +48 -0
- package/dist/component.js +116 -7
- package/dist/mime.d.ts +3 -0
- package/dist/mime.js +3 -1
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +15 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ export const signup = mutation({
|
|
|
60
60
|
- a plus-addressed token (`support+t_…@`) still threads from any sender.
|
|
61
61
|
- `replyToken: true` in the install config puts that token in the Reply-To of replies, composed emails and
|
|
62
62
|
auto-replies, as before 0.5.4.
|
|
63
|
+
- Replies and auto-replies cite the Message-ID and References exactly as the sender wrote them (Gmail only groups a
|
|
64
|
+
reply in the conversation on an exact match); lookups ignore case.
|
|
63
65
|
- It handles spam per sender per hour and auto-replies without loops.
|
|
64
66
|
- Your `onInbound` hook runs in the same transaction (tickets, push).
|
|
65
67
|
- **Inbox queries** for dashboards and mobile apps: `threads`, `thread`, `unread` (aggregate), `search`
|
|
@@ -71,6 +73,10 @@ export const signup = mutation({
|
|
|
71
73
|
(with their size). Mind the provider's message size: for a large file, put a download link in the text
|
|
72
74
|
(`ctx.storage.getUrl(file, { download, expiresIn })`) and pass it with `link: true`. The email then has no MIME part
|
|
73
75
|
for it, and the message still holds the file (the thread lists it, and it is not collected).
|
|
76
|
+
- **Retention:** `upsertMailbox({ retention: { closedDays: 730, spamDays: 30 } })` keeps closed and spam conversations
|
|
77
|
+
that many days, counted from the latest status change or message; a daily job (`lib:purge`, 04:00
|
|
78
|
+
UTC) then deletes the conversation and its messages, and file GC deletes their `.eml` and attachments. Without
|
|
79
|
+
`retention` (or with `retention: null`) nothing is deleted.
|
|
74
80
|
|
|
75
81
|
## Domains: `@freecorps/ember-email/cloudflare`
|
|
76
82
|
|
package/dist/address.d.ts
CHANGED
|
@@ -28,9 +28,13 @@ export declare function withPlus(email: string, tag: string): string;
|
|
|
28
28
|
export declare function subjectKey(subject: string | undefined): string;
|
|
29
29
|
/** "Hello" → "Re: Hello" (once). */
|
|
30
30
|
export declare function replySubject(subject: string): string;
|
|
31
|
+
/** A `Message-ID` value without the angle brackets, as written (the case kept: headers that cite it must match it). */
|
|
32
|
+
export declare function bareMessageId(id: string | undefined | null): string | null;
|
|
31
33
|
/** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
|
|
32
34
|
export declare function normalizeMessageId(id: string | undefined | null): string | null;
|
|
33
|
-
/** All Message-IDs in a `References` / `In-Reply-To` header value. */
|
|
35
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value, as written. */
|
|
36
|
+
export declare function bareMessageIdList(h: string | undefined | null): string[];
|
|
37
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value, lowercased (header lookups). */
|
|
34
38
|
export declare function messageIdList(h: string | undefined | null): string[];
|
|
35
39
|
/** Addresses that must never get an automatic reply. */
|
|
36
40
|
export declare function isNoReplySender(email: string): boolean;
|
package/dist/address.js
CHANGED
|
@@ -93,27 +93,35 @@ export function subjectKey(subject) {
|
|
|
93
93
|
export function replySubject(subject) {
|
|
94
94
|
return /^\s*re\s*:/i.test(subject) ? subject : `Re: ${subject}`;
|
|
95
95
|
}
|
|
96
|
-
/** A `Message-ID` value without the angle brackets,
|
|
97
|
-
export function
|
|
96
|
+
/** A `Message-ID` value without the angle brackets, as written (the case kept: headers that cite it must match it). */
|
|
97
|
+
export function bareMessageId(id) {
|
|
98
98
|
if (!id)
|
|
99
99
|
return null;
|
|
100
100
|
const m = /<([^<>]+)>/.exec(id);
|
|
101
101
|
const v = (m ? m[1] : id).trim();
|
|
102
|
-
return v
|
|
102
|
+
return v || null;
|
|
103
103
|
}
|
|
104
|
-
/**
|
|
105
|
-
export function
|
|
104
|
+
/** A `Message-ID` value without the angle brackets, lowercased (header lookups). */
|
|
105
|
+
export function normalizeMessageId(id) {
|
|
106
|
+
return bareMessageId(id)?.toLowerCase() ?? null;
|
|
107
|
+
}
|
|
108
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value, as written. */
|
|
109
|
+
export function bareMessageIdList(h) {
|
|
106
110
|
if (!h)
|
|
107
111
|
return [];
|
|
108
112
|
const out = [];
|
|
109
113
|
for (const m of h.matchAll(/<([^<>]+)>/g))
|
|
110
|
-
out.push(m[1].trim()
|
|
114
|
+
out.push(m[1].trim());
|
|
111
115
|
if (!out.length)
|
|
112
116
|
for (const t of h.split(/\s+/))
|
|
113
117
|
if (t.includes("@"))
|
|
114
|
-
out.push(t
|
|
118
|
+
out.push(t);
|
|
115
119
|
return out;
|
|
116
120
|
}
|
|
121
|
+
/** All Message-IDs in a `References` / `In-Reply-To` header value, lowercased (header lookups). */
|
|
122
|
+
export function messageIdList(h) {
|
|
123
|
+
return bareMessageIdList(h).map((x) => x.toLowerCase());
|
|
124
|
+
}
|
|
117
125
|
/** Addresses that must never get an automatic reply. */
|
|
118
126
|
export function isNoReplySender(email) {
|
|
119
127
|
const local = email.slice(0, email.lastIndexOf("@")).toLowerCase();
|
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;
|
|
@@ -134,6 +139,7 @@ export interface EmailComponentApi {
|
|
|
134
139
|
html?: string;
|
|
135
140
|
} | null;
|
|
136
141
|
catchAll?: boolean | null;
|
|
142
|
+
retention?: MailboxRetention | null;
|
|
137
143
|
}, string>;
|
|
138
144
|
removeMailbox: Ref<"mutation", {
|
|
139
145
|
name: string;
|
|
@@ -145,6 +151,7 @@ export interface EmailComponentApi {
|
|
|
145
151
|
displayName?: string;
|
|
146
152
|
autoReply?: unknown;
|
|
147
153
|
catchAll?: boolean;
|
|
154
|
+
retention?: MailboxRetention;
|
|
148
155
|
}>>;
|
|
149
156
|
route: Ref<"query", {
|
|
150
157
|
to: string;
|
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
|
}, ({
|
|
@@ -597,6 +606,10 @@ export declare const emailFunctions: {
|
|
|
597
606
|
html?: string;
|
|
598
607
|
} | null;
|
|
599
608
|
catchAll?: boolean | null;
|
|
609
|
+
retention?: {
|
|
610
|
+
closedDays?: number;
|
|
611
|
+
spamDays?: number;
|
|
612
|
+
} | null;
|
|
600
613
|
}, GenericId<"mailboxes">>;
|
|
601
614
|
removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
602
615
|
name: string;
|
|
@@ -615,6 +628,10 @@ export declare const emailFunctions: {
|
|
|
615
628
|
html?: string;
|
|
616
629
|
};
|
|
617
630
|
catchAll?: boolean;
|
|
631
|
+
retention?: {
|
|
632
|
+
closedDays?: number;
|
|
633
|
+
spamDays?: number;
|
|
634
|
+
};
|
|
618
635
|
}[]>;
|
|
619
636
|
route: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
620
637
|
to: string;
|
|
@@ -644,6 +661,7 @@ export declare const emailFunctions: {
|
|
|
644
661
|
lastMessageAt: number;
|
|
645
662
|
snippet: string;
|
|
646
663
|
contactName?: string;
|
|
664
|
+
activityAt?: number;
|
|
647
665
|
lastInboundAt?: number;
|
|
648
666
|
assignee?: string;
|
|
649
667
|
assigneeName?: string;
|
|
@@ -666,6 +684,7 @@ export declare const emailFunctions: {
|
|
|
666
684
|
lastMessageAt: number;
|
|
667
685
|
snippet: string;
|
|
668
686
|
contactName?: string;
|
|
687
|
+
activityAt?: number;
|
|
669
688
|
lastInboundAt?: number;
|
|
670
689
|
assignee?: string;
|
|
671
690
|
assigneeName?: string;
|
|
@@ -709,6 +728,8 @@ export declare const emailFunctions: {
|
|
|
709
728
|
html?: string;
|
|
710
729
|
truncated?: boolean;
|
|
711
730
|
inReplyTo?: string;
|
|
731
|
+
messageIdRaw?: string;
|
|
732
|
+
referencesRaw?: string[];
|
|
712
733
|
droppedAttachments?: number;
|
|
713
734
|
auth?: {
|
|
714
735
|
spf: string;
|
|
@@ -830,6 +851,7 @@ export declare const emailFunctions: {
|
|
|
830
851
|
lastMessageAt: number;
|
|
831
852
|
snippet: string;
|
|
832
853
|
contactName?: string;
|
|
854
|
+
activityAt?: number;
|
|
833
855
|
lastInboundAt?: number;
|
|
834
856
|
assignee?: string;
|
|
835
857
|
assigneeName?: string;
|
|
@@ -873,6 +895,8 @@ export declare const emailFunctions: {
|
|
|
873
895
|
html?: string;
|
|
874
896
|
truncated?: boolean;
|
|
875
897
|
inReplyTo?: string;
|
|
898
|
+
messageIdRaw?: string;
|
|
899
|
+
referencesRaw?: string[];
|
|
876
900
|
droppedAttachments?: number;
|
|
877
901
|
auth?: {
|
|
878
902
|
spf: string;
|
|
@@ -1322,6 +1346,15 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1322
1346
|
} | null>;
|
|
1323
1347
|
/** Cron: a send claimed more than LEASE_MS ago never finished (Worker died mid-send): fail it, never resend blind. */
|
|
1324
1348
|
sweep: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, number>;
|
|
1349
|
+
/**
|
|
1350
|
+
* Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
|
|
1351
|
+
* messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
|
|
1352
|
+
*/
|
|
1353
|
+
purge: import("@freecorps/ember-server").RegisteredMutation<"internal", {}, {
|
|
1354
|
+
threads: number;
|
|
1355
|
+
messages: number;
|
|
1356
|
+
more: boolean;
|
|
1357
|
+
}>;
|
|
1325
1358
|
applyEvents: import("@freecorps/ember-server").RegisteredMutation<"internal", {
|
|
1326
1359
|
events: any[];
|
|
1327
1360
|
}, ({
|
|
@@ -1406,6 +1439,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1406
1439
|
html?: string;
|
|
1407
1440
|
} | null;
|
|
1408
1441
|
catchAll?: boolean | null;
|
|
1442
|
+
retention?: {
|
|
1443
|
+
closedDays?: number;
|
|
1444
|
+
spamDays?: number;
|
|
1445
|
+
} | null;
|
|
1409
1446
|
}, GenericId<"mailboxes">>;
|
|
1410
1447
|
removeMailbox: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
1411
1448
|
name: string;
|
|
@@ -1424,6 +1461,10 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1424
1461
|
html?: string;
|
|
1425
1462
|
};
|
|
1426
1463
|
catchAll?: boolean;
|
|
1464
|
+
retention?: {
|
|
1465
|
+
closedDays?: number;
|
|
1466
|
+
spamDays?: number;
|
|
1467
|
+
};
|
|
1427
1468
|
}[]>;
|
|
1428
1469
|
route: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
1429
1470
|
to: string;
|
|
@@ -1453,6 +1494,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1453
1494
|
lastMessageAt: number;
|
|
1454
1495
|
snippet: string;
|
|
1455
1496
|
contactName?: string;
|
|
1497
|
+
activityAt?: number;
|
|
1456
1498
|
lastInboundAt?: number;
|
|
1457
1499
|
assignee?: string;
|
|
1458
1500
|
assigneeName?: string;
|
|
@@ -1475,6 +1517,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1475
1517
|
lastMessageAt: number;
|
|
1476
1518
|
snippet: string;
|
|
1477
1519
|
contactName?: string;
|
|
1520
|
+
activityAt?: number;
|
|
1478
1521
|
lastInboundAt?: number;
|
|
1479
1522
|
assignee?: string;
|
|
1480
1523
|
assigneeName?: string;
|
|
@@ -1518,6 +1561,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1518
1561
|
html?: string;
|
|
1519
1562
|
truncated?: boolean;
|
|
1520
1563
|
inReplyTo?: string;
|
|
1564
|
+
messageIdRaw?: string;
|
|
1565
|
+
referencesRaw?: string[];
|
|
1521
1566
|
droppedAttachments?: number;
|
|
1522
1567
|
auth?: {
|
|
1523
1568
|
spf: string;
|
|
@@ -1639,6 +1684,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1639
1684
|
lastMessageAt: number;
|
|
1640
1685
|
snippet: string;
|
|
1641
1686
|
contactName?: string;
|
|
1687
|
+
activityAt?: number;
|
|
1642
1688
|
lastInboundAt?: number;
|
|
1643
1689
|
assignee?: string;
|
|
1644
1690
|
assigneeName?: string;
|
|
@@ -1682,6 +1728,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1682
1728
|
html?: string;
|
|
1683
1729
|
truncated?: boolean;
|
|
1684
1730
|
inReplyTo?: string;
|
|
1731
|
+
messageIdRaw?: string;
|
|
1732
|
+
referencesRaw?: string[];
|
|
1685
1733
|
droppedAttachments?: number;
|
|
1686
1734
|
auth?: {
|
|
1687
1735
|
spf: string;
|
package/dist/component.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { componentBuilders, cronJobs, defineComponent, EmberError, v } from "@freecorps/ember-server";
|
|
22
22
|
import { rateLimit } from "@freecorps/ember-rate-limit";
|
|
23
|
-
import { domainOf, isNoReplySender, parseAddress, parseAddressList, replySubject, splitPlus, subjectKey, normalizeMessageId, withPlus, } from "./address.js";
|
|
23
|
+
import { bareMessageId, domainOf, isNoReplySender, parseAddress, parseAddressList, replySubject, splitPlus, subjectKey, normalizeMessageId, withPlus, } from "./address.js";
|
|
24
24
|
import { nextStatus, parseDeliveryEvent } from "./events.js";
|
|
25
25
|
import { parseInbound, snippetOf } from "./mime.js";
|
|
26
26
|
import { addressV, emailSchema, errorV } from "./schema.js";
|
|
@@ -477,6 +477,7 @@ function ingest(ctx, a) {
|
|
|
477
477
|
contact,
|
|
478
478
|
...((a.replyTo ?? a.from).name ? { contactName: (a.replyTo ?? a.from).name } : {}),
|
|
479
479
|
status: spam ? "spam" : "open",
|
|
480
|
+
activityAt: ctx.now,
|
|
480
481
|
token: token(ctx),
|
|
481
482
|
messageCount: 0,
|
|
482
483
|
unreadCount: 0,
|
|
@@ -485,6 +486,8 @@ function ingest(ctx, a) {
|
|
|
485
486
|
});
|
|
486
487
|
thread = ctx.db.get(id);
|
|
487
488
|
}
|
|
489
|
+
// The Message-ID as written, when it is the same id as the lookup one.
|
|
490
|
+
const idRaw = a.messageId && a.messageIdRaw?.toLowerCase() === a.messageId ? a.messageIdRaw : undefined;
|
|
488
491
|
const msgId = ctx.db.insert("messages", {
|
|
489
492
|
threadId: thread._id,
|
|
490
493
|
mailbox: a.mailbox,
|
|
@@ -492,6 +495,8 @@ function ingest(ctx, a) {
|
|
|
492
495
|
...(a.messageId ? { messageIdHeader: a.messageId } : {}),
|
|
493
496
|
...(a.inReplyTo ? { inReplyTo: a.inReplyTo } : {}),
|
|
494
497
|
references: a.references.slice(-100),
|
|
498
|
+
...(idRaw ? { messageIdRaw: idRaw } : {}),
|
|
499
|
+
...(a.referencesRaw?.length ? { referencesRaw: a.referencesRaw.slice(-100) } : {}),
|
|
495
500
|
from: a.from,
|
|
496
501
|
fromEmail: a.from.email,
|
|
497
502
|
to: a.to,
|
|
@@ -513,6 +518,7 @@ function ingest(ctx, a) {
|
|
|
513
518
|
const status = thread.status === "spam" ? "spam" : "open";
|
|
514
519
|
ctx.db.patch(thread._id, {
|
|
515
520
|
status,
|
|
521
|
+
activityAt: ctx.now,
|
|
516
522
|
messageCount: thread.messageCount + 1,
|
|
517
523
|
unreadCount: thread.unreadCount + 1,
|
|
518
524
|
lastMessageAt: ctx.now,
|
|
@@ -534,7 +540,7 @@ function ingest(ctx, a) {
|
|
|
534
540
|
subject: renderTemplateSafe(mb.autoReply.subject, data, false),
|
|
535
541
|
text,
|
|
536
542
|
html: mb.autoReply.html ? emailLayout(renderTemplateSafe(mb.autoReply.html, data, true)) : emailLayout(textToHtml(text)),
|
|
537
|
-
headers: { "Auto-Submitted": "auto-replied", ...(a.messageId ? { "In-Reply-To": `<${a.messageId}>`, References: `<${a.messageId}>` } : {}) },
|
|
543
|
+
headers: { "Auto-Submitted": "auto-replied", ...(a.messageId ? { "In-Reply-To": `<${idRaw ?? a.messageId}>`, References: `<${idRaw ?? a.messageId}>` } : {}) },
|
|
538
544
|
threadId: thread._id,
|
|
539
545
|
}).id;
|
|
540
546
|
}
|
|
@@ -567,6 +573,15 @@ function ingest(ctx, a) {
|
|
|
567
573
|
}
|
|
568
574
|
return { ...event, autoReplyId };
|
|
569
575
|
}
|
|
576
|
+
/** A message's Message-ID as its sender wrote it (the lowercased one for messages stored before 0.5.9). */
|
|
577
|
+
function citedId(m) {
|
|
578
|
+
return m.messageIdRaw ?? m.messageIdHeader ?? null;
|
|
579
|
+
}
|
|
580
|
+
/** A message's References as written, when they were kept for every id (else the lowercased ones). */
|
|
581
|
+
function citedRefs(m) {
|
|
582
|
+
const raw = m.referencesRaw;
|
|
583
|
+
return raw && raw.length === m.references.length && raw.every((r, i) => r.toLowerCase() === m.references[i]) ? raw : m.references;
|
|
584
|
+
}
|
|
570
585
|
function renderTemplateSafe(src, data, escape) {
|
|
571
586
|
try {
|
|
572
587
|
return renderTemplate(src, data, { escape });
|
|
@@ -601,10 +616,83 @@ function appendOutbound(ctx, thread, mb, a) {
|
|
|
601
616
|
ctx.db.patch(thread._id, {
|
|
602
617
|
messageCount: thread.messageCount + 1,
|
|
603
618
|
lastMessageAt: ctx.now,
|
|
619
|
+
activityAt: ctx.now,
|
|
604
620
|
...(a.note ? {} : { status: thread.status === "spam" ? "spam" : "waiting", snippet }),
|
|
605
621
|
});
|
|
606
622
|
return id;
|
|
607
623
|
}
|
|
624
|
+
const DAY_MS = 86_400_000;
|
|
625
|
+
const MAX_RETENTION_DAYS = 36_500;
|
|
626
|
+
/** Rows (messages and threads) one purge transaction deletes before it schedules the rest. */
|
|
627
|
+
const PURGE_BUDGET = 500;
|
|
628
|
+
/** Candidates one purge run looks at per mailbox and status (some may not be due yet: see `purgeExpired`). */
|
|
629
|
+
const PURGE_SCAN = 200;
|
|
630
|
+
function retentionOf(r) {
|
|
631
|
+
const out = {};
|
|
632
|
+
for (const k of ["closedDays", "spamDays"]) {
|
|
633
|
+
const d = r[k];
|
|
634
|
+
if (d === undefined)
|
|
635
|
+
continue;
|
|
636
|
+
if (!Number.isInteger(d) || d < 1 || d > MAX_RETENTION_DAYS)
|
|
637
|
+
fail("ValidationError", `retention.${k}: a whole number of days in 1..${MAX_RETENTION_DAYS}`);
|
|
638
|
+
out[k] = d;
|
|
639
|
+
}
|
|
640
|
+
return out.closedDays === undefined && out.spamDays === undefined ? undefined : out;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* One purge pass: the closed and spam threads whose latest status change or message (`activityAt`) is older than
|
|
644
|
+
* their mailbox's retention, straight from the `by_mailbox_status_activity` index (every candidate is due, so a
|
|
645
|
+
* thread kept longer never blocks the ones behind it). Threads from before `activityAt` (absent, first in the index)
|
|
646
|
+
* get it from `lastMessageAt` when they are not due yet, so they leave the head of the index.
|
|
647
|
+
*/
|
|
648
|
+
function purgeExpired(ctx) {
|
|
649
|
+
let budget = PURGE_BUDGET;
|
|
650
|
+
let threads = 0;
|
|
651
|
+
let messages = 0;
|
|
652
|
+
let more = false;
|
|
653
|
+
for (const mb of ctx.db.query("mailboxes").collect()) {
|
|
654
|
+
for (const [status, days] of [
|
|
655
|
+
["closed", mb.retention?.closedDays],
|
|
656
|
+
["spam", mb.retention?.spamDays],
|
|
657
|
+
]) {
|
|
658
|
+
if (!days || more)
|
|
659
|
+
continue;
|
|
660
|
+
const cutoff = ctx.now - days * DAY_MS;
|
|
661
|
+
const due = ctx.db
|
|
662
|
+
.query("threads")
|
|
663
|
+
.withIndex("by_mailbox_status_activity", (q) => q.eq("mailbox", mb.name).eq("status", status).lte("activityAt", cutoff))
|
|
664
|
+
.take(PURGE_SCAN);
|
|
665
|
+
for (const t of due) {
|
|
666
|
+
if (budget <= 0) {
|
|
667
|
+
more = true;
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
if (t.activityAt === undefined && t.lastMessageAt > cutoff) {
|
|
671
|
+
ctx.db.patch(t._id, { activityAt: t.lastMessageAt });
|
|
672
|
+
budget--;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const batch = ctx.db.query("messages").withIndex("by_thread", (q) => q.eq("threadId", t._id)).take(budget);
|
|
676
|
+
for (const m of batch)
|
|
677
|
+
ctx.db.delete(m._id);
|
|
678
|
+
messages += batch.length;
|
|
679
|
+
budget -= batch.length;
|
|
680
|
+
if (budget <= 0) {
|
|
681
|
+
more = true; // the thread goes on the next run, once its last messages are gone
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
ctx.db.delete(t._id);
|
|
685
|
+
threads++;
|
|
686
|
+
budget--;
|
|
687
|
+
}
|
|
688
|
+
if (due.length === PURGE_SCAN)
|
|
689
|
+
more = true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (more)
|
|
693
|
+
ctx.scheduler.runAfter(0, "lib:purge", {});
|
|
694
|
+
return { threads, messages, more };
|
|
695
|
+
}
|
|
608
696
|
// ------------------------------------------------------------------------------------------------ functions
|
|
609
697
|
const pageV = v.optional(v.object({ numItems: v.number(), cursor: v.union(v.string(), v.null()) }));
|
|
610
698
|
export const emailFunctions = {
|
|
@@ -721,7 +809,7 @@ export const emailFunctions = {
|
|
|
721
809
|
.filter((q) => q.eq(q.field("emailId"), a.emailId))
|
|
722
810
|
.first();
|
|
723
811
|
if (out && !out.messageIdHeader)
|
|
724
|
-
ctx.db.patch(out._id, { messageIdHeader: mid });
|
|
812
|
+
ctx.db.patch(out._id, { messageIdHeader: mid, messageIdRaw: bareMessageId(a.messageId) });
|
|
725
813
|
}
|
|
726
814
|
// Events can arrive before `finish` commits (the provider is fast): apply the ones already stored, exactly
|
|
727
815
|
// as `applyEvent` would have (status, suppression, onEvent hook).
|
|
@@ -765,6 +853,14 @@ export const emailFunctions = {
|
|
|
765
853
|
return n;
|
|
766
854
|
},
|
|
767
855
|
}),
|
|
856
|
+
/**
|
|
857
|
+
* Daily: deletes the closed and spam conversations past their mailbox's retention (with their messages; the
|
|
858
|
+
* messages' files are then collected by file GC). Bounded per run; schedules itself again while there is more.
|
|
859
|
+
*/
|
|
860
|
+
purge: internalMutation({
|
|
861
|
+
args: {},
|
|
862
|
+
handler: (ctx) => purgeExpired(ctx),
|
|
863
|
+
}),
|
|
768
864
|
// ---------------------------------------------------------------- events
|
|
769
865
|
applyEvents: internalMutation({
|
|
770
866
|
args: { events: v.array(v.any()) },
|
|
@@ -852,6 +948,11 @@ export const emailFunctions = {
|
|
|
852
948
|
autoReply: v.optional(v.union(v.null(), v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) }))),
|
|
853
949
|
/** The domain's catch-all (one per domain). Absent: keeps the stored value; false or null: clears it. */
|
|
854
950
|
catchAll: v.optional(v.union(v.null(), v.boolean())),
|
|
951
|
+
/**
|
|
952
|
+
* Days closed and spam conversations are kept (whole days, 1..36500; a missing kind is kept forever).
|
|
953
|
+
* Absent: keeps the stored policy; null: clears it (kept forever).
|
|
954
|
+
*/
|
|
955
|
+
retention: v.optional(v.union(v.null(), v.object({ closedDays: v.optional(v.number()), spamDays: v.optional(v.number()) }))),
|
|
855
956
|
},
|
|
856
957
|
handler: (ctx, a) => {
|
|
857
958
|
if (!/^[a-z][a-z0-9_-]{0,39}$/.test(a.name))
|
|
@@ -867,6 +968,7 @@ export const emailFunctions = {
|
|
|
867
968
|
const displayName = a.displayName === null ? undefined : (a.displayName ?? prev?.displayName);
|
|
868
969
|
const autoReply = a.autoReply === null ? undefined : (a.autoReply ?? prev?.autoReply);
|
|
869
970
|
const catchAll = a.catchAll === undefined ? !!prev?.catchAll : !!a.catchAll;
|
|
971
|
+
const retention = a.retention === null ? undefined : a.retention === undefined ? prev?.retention : retentionOf(a.retention);
|
|
870
972
|
if (catchAll) {
|
|
871
973
|
const other = catchAllOf(ctx, domainOf(address));
|
|
872
974
|
if (other && other.name !== a.name)
|
|
@@ -878,6 +980,7 @@ export const emailFunctions = {
|
|
|
878
980
|
...(displayName ? { displayName } : {}),
|
|
879
981
|
...(autoReply ? { autoReply } : {}),
|
|
880
982
|
...(catchAll ? { catchAll: true } : {}),
|
|
983
|
+
...(retention ? { retention } : {}),
|
|
881
984
|
nextNumber: prev?.nextNumber ?? 1,
|
|
882
985
|
createdAt: prev?.createdAt ?? ctx.now,
|
|
883
986
|
};
|
|
@@ -1037,7 +1140,8 @@ export const emailFunctions = {
|
|
|
1037
1140
|
const t = ctx.db.get(a.threadId);
|
|
1038
1141
|
if (!t)
|
|
1039
1142
|
return fail("NotFound", "thread not found");
|
|
1040
|
-
|
|
1143
|
+
if (t.status !== a.status)
|
|
1144
|
+
ctx.db.patch(a.threadId, { status: a.status, activityAt: ctx.now });
|
|
1041
1145
|
return a.status;
|
|
1042
1146
|
},
|
|
1043
1147
|
}),
|
|
@@ -1071,8 +1175,10 @@ export const emailFunctions = {
|
|
|
1071
1175
|
.order("desc")
|
|
1072
1176
|
.filter((q) => q.eq(q.field("direction"), "inbound"))
|
|
1073
1177
|
.first();
|
|
1074
|
-
|
|
1075
|
-
const
|
|
1178
|
+
// Cited as written: Gmail threads a reply only when In-Reply-To / References match the Message-ID exactly.
|
|
1179
|
+
const cited = last ? citedId(last) : null;
|
|
1180
|
+
const refs = last && cited ? [...citedRefs(last).slice(-20), cited] : [];
|
|
1181
|
+
const headers = refs.length ? { "In-Reply-To": `<${cited}>`, References: refs.map((r) => `<${r}>`).join(" ") } : {};
|
|
1076
1182
|
const quote = last ? `\n\n${new Date(last.at).toUTCString()}, ${last.from.name ?? last.from.email}:\n${last.text.split("\n").slice(0, 40).map((l) => `> ${l}`).join("\n")}` : "";
|
|
1077
1183
|
const html = a.html ?? emailLayout(textToHtml(a.text) + (last ? `<blockquote style="margin:16px 0 0 0;padding-left:12px;border-left:3px solid #dee2e6;color:#868e96">${textToHtml(last.text.split("\n").slice(0, 40).join("\n"))}</blockquote>` : ""));
|
|
1078
1184
|
const to = [{ email: thread.contact, ...(thread.contactName ? { name: thread.contactName } : {}) }];
|
|
@@ -1163,6 +1269,7 @@ export const emailFunctions = {
|
|
|
1163
1269
|
messageCount: 0,
|
|
1164
1270
|
unreadCount: 0,
|
|
1165
1271
|
lastMessageAt: ctx.now,
|
|
1272
|
+
activityAt: ctx.now,
|
|
1166
1273
|
snippet: snippetOf(a.text),
|
|
1167
1274
|
});
|
|
1168
1275
|
const thread = ctx.db.get(threadId);
|
|
@@ -1241,6 +1348,8 @@ async function onEmail(ctx, message) {
|
|
|
1241
1348
|
...(p.messageId ? { messageId: p.messageId } : {}),
|
|
1242
1349
|
...(p.inReplyTo ? { inReplyTo: p.inReplyTo } : {}),
|
|
1243
1350
|
references: p.references,
|
|
1351
|
+
...(p.messageIdRaw ? { messageIdRaw: p.messageIdRaw } : {}),
|
|
1352
|
+
...(p.referencesRaw.length ? { referencesRaw: p.referencesRaw } : {}),
|
|
1244
1353
|
from,
|
|
1245
1354
|
...(p.replyTo ? { replyTo: p.replyTo } : {}),
|
|
1246
1355
|
to: p.to,
|
|
@@ -1289,7 +1398,7 @@ export const email = defineComponent({
|
|
|
1289
1398
|
bindings: (config) => (config?.transport ?? "binding") === "binding"
|
|
1290
1399
|
? [{ type: "send_email", name: config?.binding ?? "EMAIL", ...(config?.allowedSenders?.length ? { allowed_sender_addresses: config.allowedSenders } : {}) }]
|
|
1291
1400
|
: [],
|
|
1292
|
-
crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep"),
|
|
1401
|
+
crons: (config) => cronJobs({ shard: config?.shard ?? "global:main" }).interval("sweep", { minutes: 10 }, "lib:sweep").daily("purge", { hour: 4 }, "lib:purge"),
|
|
1293
1402
|
queues: {
|
|
1294
1403
|
events: { shard: (config) => config?.shard ?? "global:main", consumer: { maxBatchSize: 100, maxBatchTimeoutSeconds: 2, maxRetries: 10 }, handler: onEvents },
|
|
1295
1404
|
},
|
package/dist/mime.d.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface ParsedInbound {
|
|
|
17
17
|
messageId: string | null;
|
|
18
18
|
inReplyTo: string | null;
|
|
19
19
|
references: string[];
|
|
20
|
+
/** The same Message-ID and References as written: replies cite these (Gmail compares them exactly). */
|
|
21
|
+
messageIdRaw: string | null;
|
|
22
|
+
referencesRaw: string[];
|
|
20
23
|
from: EmailAddress | null;
|
|
21
24
|
replyTo: EmailAddress | null;
|
|
22
25
|
to: EmailAddress[];
|
package/dist/mime.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// header Cloudflare's MX adds (`Authentication-Results: mx.cloudflare.net; dkim=…; dmarc=…; spf=…`): only the
|
|
4
4
|
// first such header is trusted, and only when its authserv-id is mx.cloudflare.net (the sender can add others).
|
|
5
5
|
import PostalMime, {} from "postal-mime";
|
|
6
|
-
import { messageIdList, normalizeMessageId } from "./address.js";
|
|
6
|
+
import { bareMessageId, bareMessageIdList, messageIdList, normalizeMessageId } from "./address.js";
|
|
7
7
|
import { htmlToText } from "./template.js";
|
|
8
8
|
const flat = (list) => {
|
|
9
9
|
const out = [];
|
|
@@ -92,6 +92,8 @@ export async function parseInbound(raw, limits = {}) {
|
|
|
92
92
|
messageId: normalizeMessageId(email.messageId),
|
|
93
93
|
inReplyTo: normalizeMessageId(email.inReplyTo),
|
|
94
94
|
references: messageIdList(email.references),
|
|
95
|
+
messageIdRaw: bareMessageId(email.messageId),
|
|
96
|
+
referencesRaw: bareMessageIdList(email.references),
|
|
95
97
|
from,
|
|
96
98
|
replyTo: flat(email.replyTo)[0] ?? null,
|
|
97
99
|
to: flat(email.to),
|
package/dist/schema.d.ts
CHANGED
|
@@ -171,6 +171,17 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
171
171
|
}> | undefined>>;
|
|
172
172
|
/** Receives mail for any address of its domain that no other mailbox has (one per domain). */
|
|
173
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>>;
|
|
174
185
|
nextNumber: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
175
186
|
createdAt: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
176
187
|
}>, {
|
|
@@ -187,6 +198,11 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
187
198
|
contactName: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
188
199
|
/** open | waiting | closed | spam */
|
|
189
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>;
|
|
190
206
|
/** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
|
|
191
207
|
token: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
192
208
|
messageCount: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
@@ -200,6 +216,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
200
216
|
}>, {
|
|
201
217
|
by_mailbox_last: ["mailbox", "lastMessageAt", "_creationTime"];
|
|
202
218
|
by_mailbox_status: ["mailbox", "status", "lastMessageAt", "_creationTime"];
|
|
219
|
+
by_mailbox_status_activity: ["mailbox", "status", "activityAt", "_creationTime"];
|
|
203
220
|
by_token: ["token", "_creationTime"];
|
|
204
221
|
by_contact_subject: ["mailbox", "contact", "subjectKey", "_creationTime"];
|
|
205
222
|
}, {
|
|
@@ -211,9 +228,13 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
211
228
|
mailbox: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
212
229
|
/** inbound | outbound | note */
|
|
213
230
|
direction: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
231
|
+
/** Message-ID, In-Reply-To and References lowercased, for lookups. */
|
|
214
232
|
messageIdHeader: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
215
233
|
inReplyTo: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
216
234
|
references: import("@freecorps/ember-values").Validator<string[], "required", never>;
|
|
235
|
+
/** Message-ID and References as written, for the headers of a reply (absent on messages stored before 0.5.9). */
|
|
236
|
+
messageIdRaw: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
237
|
+
referencesRaw: import("@freecorps/ember-values").Validator<string[], "optional", never>;
|
|
217
238
|
from: import("@freecorps/ember-values").Validator<{
|
|
218
239
|
email: string;
|
|
219
240
|
name?: string;
|
package/dist/schema.js
CHANGED
|
@@ -95,6 +95,11 @@ export const emailSchema = defineSchema({
|
|
|
95
95
|
autoReply: v.optional(v.object({ subject: v.string(), text: v.string(), html: v.optional(v.string()) })),
|
|
96
96
|
/** Receives mail for any address of its domain that no other mailbox has (one per domain). */
|
|
97
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()) })),
|
|
98
103
|
nextNumber: v.number(),
|
|
99
104
|
createdAt: v.number(),
|
|
100
105
|
})
|
|
@@ -110,6 +115,11 @@ export const emailSchema = defineSchema({
|
|
|
110
115
|
contactName: v.optional(v.string()),
|
|
111
116
|
/** open | waiting | closed | spam */
|
|
112
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()),
|
|
113
123
|
/** Plus-address token: replies to `support+t_<token>@` join this thread from any sender. */
|
|
114
124
|
token: v.string(),
|
|
115
125
|
messageCount: v.number(),
|
|
@@ -123,6 +133,7 @@ export const emailSchema = defineSchema({
|
|
|
123
133
|
})
|
|
124
134
|
.index("by_mailbox_last", ["mailbox", "lastMessageAt"])
|
|
125
135
|
.index("by_mailbox_status", ["mailbox", "status", "lastMessageAt"])
|
|
136
|
+
.index("by_mailbox_status_activity", ["mailbox", "status", "activityAt"])
|
|
126
137
|
.index("by_token", ["token"])
|
|
127
138
|
.index("by_contact_subject", ["mailbox", "contact", "subjectKey"])
|
|
128
139
|
.aggregate("unread", { by: ["mailbox"], count: true, sum: ["unreadCount"] }),
|
|
@@ -132,9 +143,13 @@ export const emailSchema = defineSchema({
|
|
|
132
143
|
mailbox: v.string(),
|
|
133
144
|
/** inbound | outbound | note */
|
|
134
145
|
direction: v.string(),
|
|
146
|
+
/** Message-ID, In-Reply-To and References lowercased, for lookups. */
|
|
135
147
|
messageIdHeader: v.optional(v.string()),
|
|
136
148
|
inReplyTo: v.optional(v.string()),
|
|
137
149
|
references: v.array(v.string()),
|
|
150
|
+
/** Message-ID and References as written, for the headers of a reply (absent on messages stored before 0.5.9). */
|
|
151
|
+
messageIdRaw: v.optional(v.string()),
|
|
152
|
+
referencesRaw: v.optional(v.array(v.string())),
|
|
138
153
|
from: addressV,
|
|
139
154
|
fromEmail: v.string(),
|
|
140
155
|
to: v.array(addressV),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-email",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
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.9",
|
|
56
|
+
"@freecorps/ember-server": "0.5.9",
|
|
57
|
+
"@freecorps/ember-values": "0.5.9",
|
|
58
58
|
"postal-mime": "^4.1.4"
|
|
59
59
|
}
|
|
60
60
|
}
|