@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 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, lowercased (header lookups). */
97
- export function normalizeMessageId(id) {
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 ? v.toLowerCase() : null;
102
+ return v || null;
103
103
  }
104
- /** All Message-IDs in a `References` / `In-Reply-To` header value. */
105
- export function messageIdList(h) {
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().toLowerCase());
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.toLowerCase());
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();
@@ -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
- const refs = last?.messageIdHeader ? [...last.references.slice(-20), last.messageIdHeader] : [];
1166
- const headers = refs.length ? { "In-Reply-To": `<${last.messageIdHeader}>`, References: refs.map((r) => `<${r}>`).join(" ") } : {};
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.8",
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.8",
56
- "@freecorps/ember-server": "0.5.8",
57
- "@freecorps/ember-values": "0.5.8",
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
  }