@freecorps/ember-email 0.5.8 → 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 +2 -0
- package/dist/address.d.ts +5 -1
- package/dist/address.js +15 -7
- package/dist/component.d.ts +8 -0
- package/dist/component.js +22 -5
- package/dist/mime.d.ts +3 -0
- package/dist/mime.js +3 -1
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +4 -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`
|
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/component.d.ts
CHANGED
|
@@ -728,6 +728,8 @@ export declare const emailFunctions: {
|
|
|
728
728
|
html?: string;
|
|
729
729
|
truncated?: boolean;
|
|
730
730
|
inReplyTo?: string;
|
|
731
|
+
messageIdRaw?: string;
|
|
732
|
+
referencesRaw?: string[];
|
|
731
733
|
droppedAttachments?: number;
|
|
732
734
|
auth?: {
|
|
733
735
|
spf: string;
|
|
@@ -893,6 +895,8 @@ export declare const emailFunctions: {
|
|
|
893
895
|
html?: string;
|
|
894
896
|
truncated?: boolean;
|
|
895
897
|
inReplyTo?: string;
|
|
898
|
+
messageIdRaw?: string;
|
|
899
|
+
referencesRaw?: string[];
|
|
896
900
|
droppedAttachments?: number;
|
|
897
901
|
auth?: {
|
|
898
902
|
spf: string;
|
|
@@ -1557,6 +1561,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1557
1561
|
html?: string;
|
|
1558
1562
|
truncated?: boolean;
|
|
1559
1563
|
inReplyTo?: string;
|
|
1564
|
+
messageIdRaw?: string;
|
|
1565
|
+
referencesRaw?: string[];
|
|
1560
1566
|
droppedAttachments?: number;
|
|
1561
1567
|
auth?: {
|
|
1562
1568
|
spf: string;
|
|
@@ -1722,6 +1728,8 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1722
1728
|
html?: string;
|
|
1723
1729
|
truncated?: boolean;
|
|
1724
1730
|
inReplyTo?: string;
|
|
1731
|
+
messageIdRaw?: string;
|
|
1732
|
+
referencesRaw?: string[];
|
|
1725
1733
|
droppedAttachments?: number;
|
|
1726
1734
|
auth?: {
|
|
1727
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";
|
|
@@ -486,6 +486,8 @@ function ingest(ctx, a) {
|
|
|
486
486
|
});
|
|
487
487
|
thread = ctx.db.get(id);
|
|
488
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;
|
|
489
491
|
const msgId = ctx.db.insert("messages", {
|
|
490
492
|
threadId: thread._id,
|
|
491
493
|
mailbox: a.mailbox,
|
|
@@ -493,6 +495,8 @@ function ingest(ctx, a) {
|
|
|
493
495
|
...(a.messageId ? { messageIdHeader: a.messageId } : {}),
|
|
494
496
|
...(a.inReplyTo ? { inReplyTo: a.inReplyTo } : {}),
|
|
495
497
|
references: a.references.slice(-100),
|
|
498
|
+
...(idRaw ? { messageIdRaw: idRaw } : {}),
|
|
499
|
+
...(a.referencesRaw?.length ? { referencesRaw: a.referencesRaw.slice(-100) } : {}),
|
|
496
500
|
from: a.from,
|
|
497
501
|
fromEmail: a.from.email,
|
|
498
502
|
to: a.to,
|
|
@@ -536,7 +540,7 @@ function ingest(ctx, a) {
|
|
|
536
540
|
subject: renderTemplateSafe(mb.autoReply.subject, data, false),
|
|
537
541
|
text,
|
|
538
542
|
html: mb.autoReply.html ? emailLayout(renderTemplateSafe(mb.autoReply.html, data, true)) : emailLayout(textToHtml(text)),
|
|
539
|
-
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}>` } : {}) },
|
|
540
544
|
threadId: thread._id,
|
|
541
545
|
}).id;
|
|
542
546
|
}
|
|
@@ -569,6 +573,15 @@ function ingest(ctx, a) {
|
|
|
569
573
|
}
|
|
570
574
|
return { ...event, autoReplyId };
|
|
571
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
|
+
}
|
|
572
585
|
function renderTemplateSafe(src, data, escape) {
|
|
573
586
|
try {
|
|
574
587
|
return renderTemplate(src, data, { escape });
|
|
@@ -796,7 +809,7 @@ export const emailFunctions = {
|
|
|
796
809
|
.filter((q) => q.eq(q.field("emailId"), a.emailId))
|
|
797
810
|
.first();
|
|
798
811
|
if (out && !out.messageIdHeader)
|
|
799
|
-
ctx.db.patch(out._id, { messageIdHeader: mid });
|
|
812
|
+
ctx.db.patch(out._id, { messageIdHeader: mid, messageIdRaw: bareMessageId(a.messageId) });
|
|
800
813
|
}
|
|
801
814
|
// Events can arrive before `finish` commits (the provider is fast): apply the ones already stored, exactly
|
|
802
815
|
// as `applyEvent` would have (status, suppression, onEvent hook).
|
|
@@ -1162,8 +1175,10 @@ export const emailFunctions = {
|
|
|
1162
1175
|
.order("desc")
|
|
1163
1176
|
.filter((q) => q.eq(q.field("direction"), "inbound"))
|
|
1164
1177
|
.first();
|
|
1165
|
-
|
|
1166
|
-
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(" ") } : {};
|
|
1167
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")}` : "";
|
|
1168
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>` : ""));
|
|
1169
1184
|
const to = [{ email: thread.contact, ...(thread.contactName ? { name: thread.contactName } : {}) }];
|
|
@@ -1333,6 +1348,8 @@ async function onEmail(ctx, message) {
|
|
|
1333
1348
|
...(p.messageId ? { messageId: p.messageId } : {}),
|
|
1334
1349
|
...(p.inReplyTo ? { inReplyTo: p.inReplyTo } : {}),
|
|
1335
1350
|
references: p.references,
|
|
1351
|
+
...(p.messageIdRaw ? { messageIdRaw: p.messageIdRaw } : {}),
|
|
1352
|
+
...(p.referencesRaw.length ? { referencesRaw: p.referencesRaw } : {}),
|
|
1336
1353
|
from,
|
|
1337
1354
|
...(p.replyTo ? { replyTo: p.replyTo } : {}),
|
|
1338
1355
|
to: p.to,
|
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
|
@@ -228,9 +228,13 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
|
|
|
228
228
|
mailbox: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
229
229
|
/** inbound | outbound | note */
|
|
230
230
|
direction: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
231
|
+
/** Message-ID, In-Reply-To and References lowercased, for lookups. */
|
|
231
232
|
messageIdHeader: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
232
233
|
inReplyTo: import("@freecorps/ember-values").Validator<string, "optional", never>;
|
|
233
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>;
|
|
234
238
|
from: import("@freecorps/ember-values").Validator<{
|
|
235
239
|
email: string;
|
|
236
240
|
name?: string;
|
package/dist/schema.js
CHANGED
|
@@ -143,9 +143,13 @@ export const emailSchema = defineSchema({
|
|
|
143
143
|
mailbox: v.string(),
|
|
144
144
|
/** inbound | outbound | note */
|
|
145
145
|
direction: v.string(),
|
|
146
|
+
/** Message-ID, In-Reply-To and References lowercased, for lookups. */
|
|
146
147
|
messageIdHeader: v.optional(v.string()),
|
|
147
148
|
inReplyTo: v.optional(v.string()),
|
|
148
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())),
|
|
149
153
|
from: addressV,
|
|
150
154
|
fromEmail: v.string(),
|
|
151
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
|
}
|