@freecorps/ember-email 0.5.3 → 0.5.5
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 +10 -2
- package/dist/component.d.ts +8 -0
- package/dist/component.js +33 -7
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -51,12 +51,20 @@ export const signup = mutation({
|
|
|
51
51
|
rejected.
|
|
52
52
|
- It stores the raw `.eml` and the attachments in files (R2), and parses the message with `postal-mime`.
|
|
53
53
|
- It reads DKIM, DMARC and SPF from Cloudflare's `Authentication-Results`.
|
|
54
|
-
- It threads messages
|
|
54
|
+
- It threads messages like a mail client, with no token in the Reply-To by default:
|
|
55
|
+
- an answer that cites an email the mailbox sent joins its thread when the sender is the thread's contact or one
|
|
56
|
+
of that email's recipients (In-Reply-To / References against the Message-ID the provider gave it, recorded when
|
|
57
|
+
the send succeeds; DMARC not required, since only those recipients have that id);
|
|
58
|
+
- a citation of an inbound message counts with DMARC pass from the contact;
|
|
59
|
+
- then the same contact and subject, within 30 days, if the thread is not closed or spam;
|
|
60
|
+
- a plus-addressed token (`support+t_…@`) still threads from any sender.
|
|
61
|
+
- `replyToken: true` in the install config puts that token in the Reply-To of replies, composed emails and
|
|
62
|
+
auto-replies, as before 0.5.4.
|
|
55
63
|
- It handles spam per sender per hour and auto-replies without loops.
|
|
56
64
|
- Your `onInbound` hook runs in the same transaction (tickets, push).
|
|
57
65
|
- **Inbox queries** for dashboards and mobile apps: `threads`, `thread`, `unread` (aggregate), `search`
|
|
58
66
|
(`searchIndex`), `reply`, `note`, `markRead`, `markUnread`, `setStatus`, `assign`, `compose` (a new conversation
|
|
59
|
-
from the mailbox, answers join it
|
|
67
|
+
from the mailbox, answers join it as above) and `recent` (latest threads with their messages, for offline replicas).
|
|
60
68
|
`reply` and `compose` take `attachments: [{ filename, contentType, file }]`, files already stored in the shard
|
|
61
69
|
(uploaded with `ctx.storage.generateUploadUrl`). They go with the email and stay on the thread's outbound message
|
|
62
70
|
(with their size). Mind the provider's message size: for a large file, put a download link in the text
|
package/dist/component.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export type EmailInstallConfig = {
|
|
|
58
58
|
};
|
|
59
59
|
/** Default layout for stored templates without one. */
|
|
60
60
|
layout?: LayoutOptions;
|
|
61
|
+
/**
|
|
62
|
+
* Put the thread's token in the Reply-To of inbox replies, composed emails and auto-replies (`support+t_…@`).
|
|
63
|
+
* Default false: the email goes with no Reply-To, and the answer joins the thread by the Message-ID the provider
|
|
64
|
+
* gave the sent email (In-Reply-To / References), else by contact + subject. A token in an old email's Reply-To
|
|
65
|
+
* still threads either way.
|
|
66
|
+
*/
|
|
67
|
+
replyToken?: boolean;
|
|
61
68
|
};
|
|
62
69
|
export type SendInput = {
|
|
63
70
|
project?: string;
|
|
@@ -1703,6 +1710,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
|
|
|
1703
1710
|
onInbound?: string;
|
|
1704
1711
|
onEvent?: string;
|
|
1705
1712
|
};
|
|
1713
|
+
replyToken?: boolean;
|
|
1706
1714
|
}, Record<"rateLimit", import("@freecorps/ember-server").ComponentDefinition<"rateLimit", {
|
|
1707
1715
|
lib: {
|
|
1708
1716
|
limit: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
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, withPlus, } from "./address.js";
|
|
23
|
+
import { 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";
|
|
@@ -41,6 +41,7 @@ const configV = v.optional(v.object({
|
|
|
41
41
|
spamPerHour: v.optional(v.number()),
|
|
42
42
|
hooks: v.optional(v.object({ onInbound: v.optional(v.string()), onEvent: v.optional(v.string()) })),
|
|
43
43
|
layout: v.optional(v.any()),
|
|
44
|
+
replyToken: v.optional(v.boolean()),
|
|
44
45
|
}));
|
|
45
46
|
const { query, mutation, internalQuery, internalMutation, internalAction } = componentBuilders();
|
|
46
47
|
const MAX_RECIPIENTS = 50;
|
|
@@ -79,6 +80,8 @@ const HEADER_NAME = /^[A-Za-z0-9-]{1,100}$/;
|
|
|
79
80
|
* caller puts a download link in the text, the email carries no MIME part, and the message still holds the file.
|
|
80
81
|
*/
|
|
81
82
|
const inboxAttachmentsV = v.optional(v.array(v.object({ filename: v.string(), contentType: v.string(), file: v.file(), link: v.optional(v.boolean()) })));
|
|
83
|
+
/** The Reply-To of an email in a thread: the token address only when the install asks for it (`replyToken`). */
|
|
84
|
+
const threadReplyTo = (ctx, mb, thread) => (cfg(ctx).replyToken ? { replyTo: withPlus(mb.address, `t_${thread.token}`) } : {});
|
|
82
85
|
/** The MIME attachments of the email: every file not sent as a link. */
|
|
83
86
|
const mimeParts = (list) => (list ?? []).filter((a) => !a.link).map(({ filename, contentType, file }) => ({ filename, contentType, file }));
|
|
84
87
|
/** The stored message's attachment rows (size from the file's metadata when known). */
|
|
@@ -392,14 +395,24 @@ function ingest(ctx, a) {
|
|
|
392
395
|
thread = ctx.db.query("threads").withIndex("by_token", (q) => q.eq("token", a.tag.slice(2))).first();
|
|
393
396
|
if (thread && thread.mailbox !== a.mailbox)
|
|
394
397
|
thread = null;
|
|
395
|
-
if (!thread
|
|
398
|
+
if (!thread) {
|
|
396
399
|
for (const ref of [a.inReplyTo, ...a.references.slice().reverse()].filter(Boolean)) {
|
|
397
400
|
const m = ctx.db.query("messages").withIndex("by_message_id", (q) => q.eq("mailbox", a.mailbox).eq("messageIdHeader", ref)).first();
|
|
398
401
|
if (!m)
|
|
399
402
|
continue;
|
|
400
403
|
const t = ctx.db.get(m.threadId);
|
|
401
|
-
|
|
402
|
-
|
|
404
|
+
if (!t)
|
|
405
|
+
continue;
|
|
406
|
+
// An answer to an email this mailbox sent: its Message-ID went only to that email's recipients, so it joins
|
|
407
|
+
// when the sender is the contact or one of them, DMARC or not (forwarders break DMARC). A reference to an
|
|
408
|
+
// inbound message is public (anyone in the conversation has it): DMARC must pass and the From be the contact.
|
|
409
|
+
// Who sent it is the From address, never the Reply-To: anyone writes a Reply-To, and DMARC vouches for the
|
|
410
|
+
// From's domain only.
|
|
411
|
+
const sender = a.from.email;
|
|
412
|
+
const sentTo = m.direction === "outbound" ? [...(m.to ?? []), ...(m.cc ?? [])].map((x) => x.email) : [];
|
|
413
|
+
const ok = m.direction === "outbound" ? sender === t.contact || sentTo.includes(sender) : a.auth.dmarc === "pass" && sender === t.contact;
|
|
414
|
+
// Otherwise keep looking (an older reference may be this contact's own thread).
|
|
415
|
+
if (ok) {
|
|
403
416
|
thread = t;
|
|
404
417
|
break;
|
|
405
418
|
}
|
|
@@ -486,7 +499,7 @@ function ingest(ctx, a) {
|
|
|
486
499
|
project: `mailbox:${a.mailbox}`,
|
|
487
500
|
from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
|
|
488
501
|
to: contact,
|
|
489
|
-
|
|
502
|
+
...threadReplyTo(ctx, mb, thread),
|
|
490
503
|
subject: renderTemplateSafe(mb.autoReply.subject, data, false),
|
|
491
504
|
text,
|
|
492
505
|
html: mb.autoReply.html ? emailLayout(renderTemplateSafe(mb.autoReply.html, data, true)) : emailLayout(textToHtml(text)),
|
|
@@ -666,6 +679,19 @@ export const emailFunctions = {
|
|
|
666
679
|
const extra = a.droppedHeaders?.length ? { droppedHeaders: a.droppedHeaders } : {};
|
|
667
680
|
if (a.outcome === "sent") {
|
|
668
681
|
ctx.db.patch(a.emailId, { status: "sent", messageId: a.messageId, sentAt: ctx.now, error: undefined, ...extra });
|
|
682
|
+
// The provider's id is the email's Message-ID: the thread's outbound message records it, so an answer
|
|
683
|
+
// (In-Reply-To / References) finds its thread without a token in the Reply-To.
|
|
684
|
+
const mid = normalizeMessageId(a.messageId);
|
|
685
|
+
if (e.threadId && mid) {
|
|
686
|
+
const out = ctx.db
|
|
687
|
+
.query("messages")
|
|
688
|
+
.withIndex("by_thread", (q) => q.eq("threadId", e.threadId))
|
|
689
|
+
.order("desc")
|
|
690
|
+
.filter((q) => q.eq(q.field("emailId"), a.emailId))
|
|
691
|
+
.first();
|
|
692
|
+
if (out && !out.messageIdHeader)
|
|
693
|
+
ctx.db.patch(out._id, { messageIdHeader: mid });
|
|
694
|
+
}
|
|
669
695
|
// Events can arrive before `finish` commits (the provider is fast): apply the ones already stored, exactly
|
|
670
696
|
// as `applyEvent` would have (status, suppression, onEvent hook).
|
|
671
697
|
for (const ev of ctx.db.query("events").withIndex("by_message", (q) => q.eq("messageId", a.messageId)).collect()) {
|
|
@@ -932,7 +958,7 @@ export const emailFunctions = {
|
|
|
932
958
|
from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
|
|
933
959
|
to,
|
|
934
960
|
...(a.cc?.length ? { cc: a.cc } : {}),
|
|
935
|
-
|
|
961
|
+
...threadReplyTo(ctx, mb, thread),
|
|
936
962
|
subject: replySubject(thread.subject),
|
|
937
963
|
text: a.text + quote,
|
|
938
964
|
html,
|
|
@@ -1022,7 +1048,7 @@ export const emailFunctions = {
|
|
|
1022
1048
|
from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
|
|
1023
1049
|
to,
|
|
1024
1050
|
...(a.cc?.length ? { cc: a.cc } : {}),
|
|
1025
|
-
|
|
1051
|
+
...threadReplyTo(ctx, mb, thread),
|
|
1026
1052
|
subject,
|
|
1027
1053
|
text: a.text,
|
|
1028
1054
|
html: a.html ?? emailLayout(textToHtml(a.text)),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-email",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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.5",
|
|
56
|
+
"@freecorps/ember-server": "0.5.5",
|
|
57
|
+
"@freecorps/ember-values": "0.5.5",
|
|
58
58
|
"postal-mime": "^4.1.4"
|
|
59
59
|
}
|
|
60
60
|
}
|