@freecorps/ember-email 0.5.2 → 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,25 @@ 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).
68
+ `reply` and `compose` take `attachments: [{ filename, contentType, file }]`, files already stored in the shard
69
+ (uploaded with `ctx.storage.generateUploadUrl`). They go with the email and stay on the thread's outbound message
70
+ (with their size). Mind the provider's message size: for a large file, put a download link in the text
71
+ (`ctx.storage.getUrl(file, { download, expiresIn })`) and pass it with `link: true`. The email then has no MIME part
72
+ for it, and the message still holds the file (the thread lists it, and it is not collected).
60
73
 
61
74
  ## Domains: `@freecorps/ember-email/cloudflare`
62
75
 
@@ -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;
@@ -252,6 +259,7 @@ export declare const emailFunctions: {
252
259
  contentId?: string;
253
260
  inline?: boolean;
254
261
  size?: number;
262
+ link?: boolean;
255
263
  }[];
256
264
  replyTo?: {
257
265
  email: string;
@@ -316,6 +324,7 @@ export declare const emailFunctions: {
316
324
  contentId?: string;
317
325
  inline?: boolean;
318
326
  size?: number;
327
+ link?: boolean;
319
328
  }[];
320
329
  replyTo?: {
321
330
  email: string;
@@ -419,6 +428,7 @@ export declare const emailFunctions: {
419
428
  contentId?: string;
420
429
  inline?: boolean;
421
430
  size?: number;
431
+ link?: boolean;
422
432
  }[];
423
433
  replyTo?: {
424
434
  email: string;
@@ -674,6 +684,7 @@ export declare const emailFunctions: {
674
684
  contentId?: string;
675
685
  inline?: boolean;
676
686
  size?: number;
687
+ link?: boolean;
677
688
  }[];
678
689
  references: string[];
679
690
  to: {
@@ -731,6 +742,12 @@ export declare const emailFunctions: {
731
742
  text: string;
732
743
  threadId: GenericId<"threads">;
733
744
  html?: string;
745
+ attachments?: {
746
+ filename: string;
747
+ contentType: string;
748
+ file: import("@freecorps/ember-values").FileId;
749
+ link?: boolean;
750
+ }[];
734
751
  cc?: string[];
735
752
  sendAt?: number;
736
753
  author?: string;
@@ -759,6 +776,12 @@ export declare const emailFunctions: {
759
776
  subject: string;
760
777
  mailbox: string;
761
778
  html?: string;
779
+ attachments?: {
780
+ filename: string;
781
+ contentType: string;
782
+ file: import("@freecorps/ember-values").FileId;
783
+ link?: boolean;
784
+ }[];
762
785
  cc?: string[];
763
786
  author?: string;
764
787
  }, {
@@ -811,6 +834,7 @@ export declare const emailFunctions: {
811
834
  contentId?: string;
812
835
  inline?: boolean;
813
836
  size?: number;
837
+ link?: boolean;
814
838
  }[];
815
839
  references: string[];
816
840
  to: {
@@ -1028,6 +1052,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1028
1052
  contentId?: string;
1029
1053
  inline?: boolean;
1030
1054
  size?: number;
1055
+ link?: boolean;
1031
1056
  }[];
1032
1057
  replyTo?: {
1033
1058
  email: string;
@@ -1092,6 +1117,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1092
1117
  contentId?: string;
1093
1118
  inline?: boolean;
1094
1119
  size?: number;
1120
+ link?: boolean;
1095
1121
  }[];
1096
1122
  replyTo?: {
1097
1123
  email: string;
@@ -1195,6 +1221,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1195
1221
  contentId?: string;
1196
1222
  inline?: boolean;
1197
1223
  size?: number;
1224
+ link?: boolean;
1198
1225
  }[];
1199
1226
  replyTo?: {
1200
1227
  email: string;
@@ -1450,6 +1477,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1450
1477
  contentId?: string;
1451
1478
  inline?: boolean;
1452
1479
  size?: number;
1480
+ link?: boolean;
1453
1481
  }[];
1454
1482
  references: string[];
1455
1483
  to: {
@@ -1507,6 +1535,12 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1507
1535
  text: string;
1508
1536
  threadId: GenericId<"threads">;
1509
1537
  html?: string;
1538
+ attachments?: {
1539
+ filename: string;
1540
+ contentType: string;
1541
+ file: import("@freecorps/ember-values").FileId;
1542
+ link?: boolean;
1543
+ }[];
1510
1544
  cc?: string[];
1511
1545
  sendAt?: number;
1512
1546
  author?: string;
@@ -1535,6 +1569,12 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1535
1569
  subject: string;
1536
1570
  mailbox: string;
1537
1571
  html?: string;
1572
+ attachments?: {
1573
+ filename: string;
1574
+ contentType: string;
1575
+ file: import("@freecorps/ember-values").FileId;
1576
+ link?: boolean;
1577
+ }[];
1538
1578
  cc?: string[];
1539
1579
  author?: string;
1540
1580
  }, {
@@ -1587,6 +1627,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1587
1627
  contentId?: string;
1588
1628
  inline?: boolean;
1589
1629
  size?: number;
1630
+ link?: boolean;
1590
1631
  }[];
1591
1632
  references: string[];
1592
1633
  to: {
@@ -1669,6 +1710,7 @@ export declare const email: import("@freecorps/ember-server").ComponentDefinitio
1669
1710
  onInbound?: string;
1670
1711
  onEvent?: string;
1671
1712
  };
1713
+ replyToken?: boolean;
1672
1714
  }, Record<"rateLimit", import("@freecorps/ember-server").ComponentDefinition<"rateLimit", {
1673
1715
  lib: {
1674
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;
@@ -74,6 +75,22 @@ const sendArgs = {
74
75
  threadId: v.optional(v.string()),
75
76
  };
76
77
  const HEADER_NAME = /^[A-Za-z0-9-]{1,100}$/;
78
+ /**
79
+ * Files already stored in this shard, for an inbox reply or a composed email. `link: true`: too large to attach; the
80
+ * caller puts a download link in the text, the email carries no MIME part, and the message still holds the file.
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}`) } : {});
85
+ /** The MIME attachments of the email: every file not sent as a link. */
86
+ const mimeParts = (list) => (list ?? []).filter((a) => !a.link).map(({ filename, contentType, file }) => ({ filename, contentType, file }));
87
+ /** The stored message's attachment rows (size from the file's metadata when known). */
88
+ function attachmentRows(ctx, list) {
89
+ return (list ?? []).map((a) => {
90
+ const meta = ctx.storage?.getMetadata?.(a.file);
91
+ return { filename: a.filename, contentType: a.contentType, file: a.file, ...(meta?.size != null ? { size: meta.size } : {}), ...(a.link ? { link: true } : {}) };
92
+ });
93
+ }
77
94
  const RESERVED_HEADERS = new Set(["from", "to", "cc", "bcc", "subject", "reply-to", "content-type", "mime-version", "content-transfer-encoding"]);
78
95
  function doSend(ctx, a) {
79
96
  const project = a.project ?? "default";
@@ -378,14 +395,24 @@ function ingest(ctx, a) {
378
395
  thread = ctx.db.query("threads").withIndex("by_token", (q) => q.eq("token", a.tag.slice(2))).first();
379
396
  if (thread && thread.mailbox !== a.mailbox)
380
397
  thread = null;
381
- if (!thread && a.auth.dmarc === "pass") {
398
+ if (!thread) {
382
399
  for (const ref of [a.inReplyTo, ...a.references.slice().reverse()].filter(Boolean)) {
383
400
  const m = ctx.db.query("messages").withIndex("by_message_id", (q) => q.eq("mailbox", a.mailbox).eq("messageIdHeader", ref)).first();
384
401
  if (!m)
385
402
  continue;
386
403
  const t = ctx.db.get(m.threadId);
387
- // Another contact's thread: keep looking (an older reference may be this contact's own thread).
388
- 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) {
389
416
  thread = t;
390
417
  break;
391
418
  }
@@ -472,7 +499,7 @@ function ingest(ctx, a) {
472
499
  project: `mailbox:${a.mailbox}`,
473
500
  from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
474
501
  to: contact,
475
- replyTo: withPlus(mb.address, `t_${thread.token}`),
502
+ ...threadReplyTo(ctx, mb, thread),
476
503
  subject: renderTemplateSafe(mb.autoReply.subject, data, false),
477
504
  text,
478
505
  html: mb.autoReply.html ? emailLayout(renderTemplateSafe(mb.autoReply.html, data, true)) : emailLayout(textToHtml(text)),
@@ -533,8 +560,8 @@ function appendOutbound(ctx, thread, mb, a) {
533
560
  text: a.text,
534
561
  ...(a.html ? { html: a.html } : {}),
535
562
  snippet,
536
- searchText: `${thread.subject}\n${a.author ?? ""}\n${a.text}`.slice(0, 100_000),
537
- attachments: [],
563
+ searchText: `${thread.subject}\n${a.author ?? ""}\n${a.text}${(a.attachments ?? []).map((x) => `\n${x.filename}`).join("")}`.slice(0, 100_000),
564
+ attachments: a.attachments ?? [],
538
565
  autoSubmitted: false,
539
566
  ...(a.emailId ? { emailId: a.emailId } : {}),
540
567
  ...(a.author ? { author: a.author } : {}),
@@ -652,6 +679,19 @@ export const emailFunctions = {
652
679
  const extra = a.droppedHeaders?.length ? { droppedHeaders: a.droppedHeaders } : {};
653
680
  if (a.outcome === "sent") {
654
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
+ }
655
695
  // Events can arrive before `finish` commits (the provider is fast): apply the ones already stored, exactly
656
696
  // as `applyEvent` would have (status, suppression, onEvent hook).
657
697
  for (const ev of ctx.db.query("events").withIndex("by_message", (q) => q.eq("messageId", a.messageId)).collect()) {
@@ -880,7 +920,15 @@ export const emailFunctions = {
880
920
  }),
881
921
  /** Replies from the inbox: sent from the mailbox address, Reply-To carries the thread token, threading headers set. */
882
922
  reply: mutation({
883
- args: { threadId: v.id("threads"), text: v.string(), html: v.optional(v.string()), author: v.optional(v.string()), cc: v.optional(v.array(v.string())), sendAt: v.optional(v.number()) },
923
+ args: {
924
+ threadId: v.id("threads"),
925
+ text: v.string(),
926
+ html: v.optional(v.string()),
927
+ author: v.optional(v.string()),
928
+ cc: v.optional(v.array(v.string())),
929
+ sendAt: v.optional(v.number()),
930
+ attachments: inboxAttachmentsV,
931
+ },
884
932
  handler: (ctx, a) => {
885
933
  const thread = ctx.db.get(a.threadId);
886
934
  if (!thread)
@@ -910,15 +958,17 @@ export const emailFunctions = {
910
958
  from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
911
959
  to,
912
960
  ...(a.cc?.length ? { cc: a.cc } : {}),
913
- replyTo: withPlus(mb.address, `t_${thread.token}`),
961
+ ...threadReplyTo(ctx, mb, thread),
914
962
  subject: replySubject(thread.subject),
915
963
  text: a.text + quote,
916
964
  html,
917
965
  headers,
918
966
  threadId: thread._id,
919
967
  ...(a.sendAt ? { sendAt: a.sendAt } : {}),
968
+ ...(mimeParts(a.attachments).length ? { attachments: mimeParts(a.attachments) } : {}),
920
969
  });
921
- const messageId = appendOutbound(ctx, thread, mb, { text: a.text, ...(a.html ? { html: a.html } : {}), emailId: sent.id, ...(a.author ? { author: a.author } : {}), to, ...(a.cc?.length ? { cc: parseAddressList(a.cc) } : {}) });
970
+ const files = attachmentRows(ctx, a.attachments);
971
+ const messageId = appendOutbound(ctx, thread, mb, { text: a.text, ...(a.html ? { html: a.html } : {}), emailId: sent.id, ...(a.author ? { author: a.author } : {}), to, ...(a.cc?.length ? { cc: parseAddressList(a.cc) } : {}), attachments: files });
922
972
  return { emailId: sent.id, messageId, status: sent.status };
923
973
  },
924
974
  }),
@@ -956,6 +1006,7 @@ export const emailFunctions = {
956
1006
  text: v.string(),
957
1007
  html: v.optional(v.string()),
958
1008
  author: v.optional(v.string()),
1009
+ attachments: inboxAttachmentsV,
959
1010
  },
960
1011
  handler: (ctx, a) => {
961
1012
  const mb = ctx.db.query("mailboxes").withIndex("by_name", (q) => q.eq("name", a.mailbox)).first();
@@ -997,13 +1048,15 @@ export const emailFunctions = {
997
1048
  from: { email: mb.address, ...(mb.displayName ? { name: mb.displayName } : {}) },
998
1049
  to,
999
1050
  ...(a.cc?.length ? { cc: a.cc } : {}),
1000
- replyTo: withPlus(mb.address, `t_${thread.token}`),
1051
+ ...threadReplyTo(ctx, mb, thread),
1001
1052
  subject,
1002
1053
  text: a.text,
1003
1054
  html: a.html ?? emailLayout(textToHtml(a.text)),
1004
1055
  threadId,
1056
+ ...(mimeParts(a.attachments).length ? { attachments: mimeParts(a.attachments) } : {}),
1005
1057
  });
1006
- const messageId = appendOutbound(ctx, thread, mb, { text: a.text, ...(a.html ? { html: a.html } : {}), emailId: sent.id, ...(a.author ? { author: a.author } : {}), to, ...(a.cc?.length ? { cc: parseAddressList(a.cc) } : {}) });
1058
+ const files = attachmentRows(ctx, a.attachments);
1059
+ const messageId = appendOutbound(ctx, thread, mb, { text: a.text, ...(a.html ? { html: a.html } : {}), emailId: sent.id, ...(a.author ? { author: a.author } : {}), to, ...(a.cc?.length ? { cc: parseAddressList(a.cc) } : {}), attachments: files });
1007
1060
  return { threadId, emailId: sent.id, messageId, status: sent.status, number };
1008
1061
  },
1009
1062
  }),
package/dist/schema.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare const attachmentV: import("@freecorps/ember-values").Validator<{
12
12
  contentId?: string;
13
13
  inline?: boolean;
14
14
  size?: number;
15
+ link?: boolean;
15
16
  }, "required", import("@freecorps/ember-values").ObjectFieldPaths<{
16
17
  filename: import("@freecorps/ember-values").Validator<string, "required", never>;
17
18
  contentType: import("@freecorps/ember-values").Validator<string, "required", never>;
@@ -19,6 +20,8 @@ export declare const attachmentV: import("@freecorps/ember-values").Validator<{
19
20
  size: import("@freecorps/ember-values").Validator<number, "optional", never>;
20
21
  contentId: import("@freecorps/ember-values").Validator<string, "optional", never>;
21
22
  inline: import("@freecorps/ember-values").Validator<boolean, "optional", never>;
23
+ /** Sent as a link in the body (too large to attach), not as a MIME part: the message still holds the file. */
24
+ link: import("@freecorps/ember-values").Validator<boolean, "optional", never>;
22
25
  }>>;
23
26
  export declare const errorV: import("@freecorps/ember-values").Validator<{
24
27
  code: string;
@@ -68,6 +71,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
68
71
  contentId?: string;
69
72
  inline?: boolean;
70
73
  size?: number;
74
+ link?: boolean;
71
75
  }[], "optional", never>;
72
76
  template: import("@freecorps/ember-values").Validator<{
73
77
  name: string;
@@ -239,6 +243,7 @@ export declare const emailSchema: import("@freecorps/ember-server").SchemaDefini
239
243
  contentId?: string;
240
244
  inline?: boolean;
241
245
  size?: number;
246
+ link?: boolean;
242
247
  }[], "required", never>;
243
248
  droppedAttachments: import("@freecorps/ember-values").Validator<number, "optional", never>;
244
249
  auth: import("@freecorps/ember-values").Validator<{
package/dist/schema.js CHANGED
@@ -8,6 +8,8 @@ export const attachmentV = v.object({
8
8
  size: v.optional(v.number()),
9
9
  contentId: v.optional(v.string()),
10
10
  inline: v.optional(v.boolean()),
11
+ /** Sent as a link in the body (too large to attach), not as a MIME part: the message still holds the file. */
12
+ link: v.optional(v.boolean()),
11
13
  });
12
14
  export const errorV = v.object({ code: v.string(), message: v.string() });
13
15
  export const emailSchema = defineSchema({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@freecorps/ember-email",
3
- "version": "0.5.2",
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.2",
56
- "@freecorps/ember-server": "0.5.2",
57
- "@freecorps/ember-values": "0.5.2",
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
  }