@freecorps/ember-email 0.5.3 → 0.5.4

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 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 by token, then by `In-Reply-To` (DMARC pass, same contact), then by subject.
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 by token) and `recent` (latest threads with their messages, for offline replicas).
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
@@ -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 && a.auth.dmarc === "pass") {
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
- // Another contact's thread: keep looking (an older reference may be this contact's own thread).
402
- if (t && t.contact === contact) {
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
- replyTo: withPlus(mb.address, `t_${thread.token}`),
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
- replyTo: withPlus(mb.address, `t_${thread.token}`),
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
- replyTo: withPlus(mb.address, `t_${thread.token}`),
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",
3
+ "version": "0.5.4",
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.3",
56
- "@freecorps/ember-server": "0.5.3",
57
- "@freecorps/ember-values": "0.5.3",
55
+ "@freecorps/ember-rate-limit": "0.5.4",
56
+ "@freecorps/ember-server": "0.5.4",
57
+ "@freecorps/ember-values": "0.5.4",
58
58
  "postal-mime": "^4.1.4"
59
59
  }
60
60
  }