agent-inbox 0.2.3 → 0.2.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.
Files changed (122) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/dist/federation/connection-manager.d.ts +8 -0
  5. package/dist/federation/connection-manager.d.ts.map +1 -1
  6. package/dist/federation/connection-manager.js +12 -0
  7. package/dist/federation/connection-manager.js.map +1 -1
  8. package/dist/federation/delivery-queue.d.ts +11 -3
  9. package/dist/federation/delivery-queue.d.ts.map +1 -1
  10. package/dist/federation/delivery-queue.js +38 -8
  11. package/dist/federation/delivery-queue.js.map +1 -1
  12. package/dist/federation/queue-store.d.ts +42 -0
  13. package/dist/federation/queue-store.d.ts.map +1 -0
  14. package/dist/federation/queue-store.js +87 -0
  15. package/dist/federation/queue-store.js.map +1 -0
  16. package/dist/index.d.mts +2 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +98 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +1 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/mail/address-book.d.ts +43 -0
  24. package/dist/mail/address-book.d.ts.map +1 -0
  25. package/dist/mail/address-book.js +95 -0
  26. package/dist/mail/address-book.js.map +1 -0
  27. package/dist/mail/attachment-store.d.ts +31 -0
  28. package/dist/mail/attachment-store.d.ts.map +1 -0
  29. package/dist/mail/attachment-store.js +74 -0
  30. package/dist/mail/attachment-store.js.map +1 -0
  31. package/dist/mail/email-mapper.d.ts +41 -0
  32. package/dist/mail/email-mapper.d.ts.map +1 -0
  33. package/dist/mail/email-mapper.js +216 -0
  34. package/dist/mail/email-mapper.js.map +1 -0
  35. package/dist/mail/fs-attachment-store.d.ts +38 -0
  36. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  37. package/dist/mail/fs-attachment-store.js +165 -0
  38. package/dist/mail/fs-attachment-store.js.map +1 -0
  39. package/dist/mail/mail-gateway.d.ts +114 -0
  40. package/dist/mail/mail-gateway.d.ts.map +1 -0
  41. package/dist/mail/mail-gateway.js +402 -0
  42. package/dist/mail/mail-gateway.js.map +1 -0
  43. package/dist/mail/provider-transport.d.ts +138 -0
  44. package/dist/mail/provider-transport.d.ts.map +1 -0
  45. package/dist/mail/provider-transport.js +434 -0
  46. package/dist/mail/provider-transport.js.map +1 -0
  47. package/dist/mail/rate-limiter.d.ts +20 -0
  48. package/dist/mail/rate-limiter.d.ts.map +1 -0
  49. package/dist/mail/rate-limiter.js +56 -0
  50. package/dist/mail/rate-limiter.js.map +1 -0
  51. package/dist/mail/smtp-transport.d.ts +141 -0
  52. package/dist/mail/smtp-transport.d.ts.map +1 -0
  53. package/dist/mail/smtp-transport.js +415 -0
  54. package/dist/mail/smtp-transport.js.map +1 -0
  55. package/dist/mail/types.d.ts +177 -0
  56. package/dist/mail/types.d.ts.map +1 -0
  57. package/dist/mail/types.js +11 -0
  58. package/dist/mail/types.js.map +1 -0
  59. package/dist/router/destination.d.ts +69 -0
  60. package/dist/router/destination.d.ts.map +1 -0
  61. package/dist/router/destination.js +106 -0
  62. package/dist/router/destination.js.map +1 -0
  63. package/dist/router/message-router.d.ts +15 -0
  64. package/dist/router/message-router.d.ts.map +1 -1
  65. package/dist/router/message-router.js +25 -3
  66. package/dist/router/message-router.js.map +1 -1
  67. package/dist/storage/interface.d.ts +9 -0
  68. package/dist/storage/interface.d.ts.map +1 -1
  69. package/dist/storage/memory.d.ts +4 -0
  70. package/dist/storage/memory.d.ts.map +1 -1
  71. package/dist/storage/memory.js +12 -0
  72. package/dist/storage/memory.js.map +1 -1
  73. package/dist/storage/sqlite.d.ts +6 -0
  74. package/dist/storage/sqlite.d.ts.map +1 -1
  75. package/dist/storage/sqlite.js +28 -0
  76. package/dist/storage/sqlite.js.map +1 -1
  77. package/dist/types.d.ts +79 -0
  78. package/dist/types.d.ts.map +1 -1
  79. package/docs/DESIGN.md +15 -0
  80. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  81. package/package.json +28 -3
  82. package/renovate.json5 +6 -0
  83. package/rules/agent-inbox.md +1 -0
  84. package/src/federation/connection-manager.ts +12 -0
  85. package/src/federation/delivery-queue.ts +38 -8
  86. package/src/federation/queue-store.ts +124 -0
  87. package/src/index.ts +148 -0
  88. package/src/mail/address-book.ts +111 -0
  89. package/src/mail/attachment-store.ts +90 -0
  90. package/src/mail/email-mapper.ts +288 -0
  91. package/src/mail/fs-attachment-store.ts +163 -0
  92. package/src/mail/mail-gateway.ts +505 -0
  93. package/src/mail/provider-transport.ts +577 -0
  94. package/src/mail/rate-limiter.ts +51 -0
  95. package/src/mail/smtp-transport.ts +589 -0
  96. package/src/mail/types.ts +221 -0
  97. package/src/router/destination.ts +140 -0
  98. package/src/router/message-router.ts +41 -4
  99. package/src/storage/interface.ts +11 -0
  100. package/src/storage/memory.ts +15 -0
  101. package/src/storage/sqlite.ts +36 -0
  102. package/src/types.ts +73 -0
  103. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  104. package/test/load.test.ts +1 -1
  105. package/test/mail/address-book.test.ts +111 -0
  106. package/test/mail/attachment-store-contract.test.ts +92 -0
  107. package/test/mail/attachment-store.test.ts +69 -0
  108. package/test/mail/destination.test.ts +115 -0
  109. package/test/mail/dsn-parse.test.ts +239 -0
  110. package/test/mail/email-mapper.test.ts +341 -0
  111. package/test/mail/external-id.test.ts +43 -0
  112. package/test/mail/fs-attachment-store.test.ts +134 -0
  113. package/test/mail/full-flow-e2e.test.ts +200 -0
  114. package/test/mail/mail-gateway.test.ts +419 -0
  115. package/test/mail/mail-transport-contract.test.ts +134 -0
  116. package/test/mail/mock-mail.ts +161 -0
  117. package/test/mail/mock-postmark.ts +66 -0
  118. package/test/mail/provider-transport.test.ts +381 -0
  119. package/test/mail/rate-limiter.test.ts +48 -0
  120. package/test/mail/router-mail-integration.test.ts +138 -0
  121. package/test/mail/smtp-e2e.test.ts +98 -0
  122. package/test/mail/smtp-transport.test.ts +138 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Attachment byte stores.
3
+ *
4
+ * `MailAttachment.contentRef` is opaque; bytes live behind an AttachmentStore.
5
+ * Default impl is SQLite-backed (atomic with the message, single-file backup,
6
+ * works with the in-memory DB used in tests). A filesystem impl is opt-in for
7
+ * large/high-volume deployments (added later). Refs are content-addressed
8
+ * (sha256) so identical bytes dedup. See docs/MAIL-INTEROP-PLAN.md §4c.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ export class SqliteAttachmentStore {
12
+ db;
13
+ table;
14
+ putStmt;
15
+ getStmt;
16
+ delStmt;
17
+ /**
18
+ * @param db A better-sqlite3 handle (lifecycle owned by the caller).
19
+ * @param prefix Table name prefix to match the Storage convention. Default "".
20
+ */
21
+ constructor(db, prefix = "") {
22
+ this.db = db;
23
+ this.table = `${prefix}mail_attachments`;
24
+ this.migrate();
25
+ this.putStmt = this.db.prepare(`INSERT INTO ${this.table} (ref, content_type, filename, bytes, size_bytes, created_at)
26
+ VALUES (@ref, @content_type, @filename, @bytes, @size_bytes, @created_at)
27
+ ON CONFLICT(ref) DO NOTHING`);
28
+ this.getStmt = this.db.prepare(`SELECT bytes FROM ${this.table} WHERE ref = ?`);
29
+ this.delStmt = this.db.prepare(`DELETE FROM ${this.table} WHERE ref = ?`);
30
+ }
31
+ migrate() {
32
+ this.db.exec(`
33
+ CREATE TABLE IF NOT EXISTS ${this.table} (
34
+ ref TEXT PRIMARY KEY,
35
+ content_type TEXT NOT NULL,
36
+ filename TEXT,
37
+ bytes BLOB NOT NULL,
38
+ size_bytes INTEGER NOT NULL,
39
+ created_at TEXT NOT NULL
40
+ );
41
+ `);
42
+ }
43
+ async put(bytes, meta) {
44
+ const ref = "sha256:" + createHash("sha256").update(bytes).digest("hex");
45
+ // ON CONFLICT DO NOTHING — content-addressed, so identical bytes are deduped.
46
+ this.putStmt.run({
47
+ ref,
48
+ content_type: meta.contentType,
49
+ filename: meta.filename ?? null,
50
+ bytes,
51
+ size_bytes: bytes.length,
52
+ created_at: new Date().toISOString(),
53
+ });
54
+ return ref;
55
+ }
56
+ async get(ref) {
57
+ assertValidRef(ref);
58
+ const row = this.getStmt.get(ref);
59
+ if (!row)
60
+ throw new Error(`Attachment not found: ${ref}`);
61
+ return row.bytes;
62
+ }
63
+ async delete(ref) {
64
+ assertValidRef(ref);
65
+ this.delStmt.run(ref);
66
+ }
67
+ }
68
+ /** Refs are always sha256-of-content; reject anything else (parity with FS store). */
69
+ function assertValidRef(ref) {
70
+ if (!/^sha256:[0-9a-f]{64}$/.test(ref)) {
71
+ throw new Error(`Invalid attachment ref: ${ref}`);
72
+ }
73
+ }
74
+ //# sourceMappingURL=attachment-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment-store.js","sourceRoot":"","sources":["../../src/mail/attachment-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,MAAM,OAAO,qBAAqB;IAWtB;IAVF,KAAK,CAAS;IACd,OAAO,CAAqB;IAC5B,OAAO,CAAqB;IAC5B,OAAO,CAAqB;IAEpC;;;OAGG;IACH,YACU,EAAqB,EAC7B,SAAiB,EAAE;QADX,OAAE,GAAF,EAAE,CAAmB;QAG7B,IAAI,CAAC,KAAK,GAAG,GAAG,MAAM,kBAAkB,CAAC;QACzC,IAAI,CAAC,OAAO,EAAE,CAAC;QACf,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC5B,eAAe,IAAI,CAAC,KAAK;;mCAEI,CAC9B,CAAC;QACF,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAC5B,qBAAqB,IAAI,CAAC,KAAK,gBAAgB,CAChD,CAAC;QACF,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,EAAE,CAAC,OAAO,CAAC,eAAe,IAAI,CAAC,KAAK,gBAAgB,CAAC,CAAC;IAC5E,CAAC;IAEO,OAAO;QACb,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC;mCACkB,IAAI,CAAC,KAAK;;;;;;;;KAQxC,CAAC,CAAC;IACL,CAAC;IAED,KAAK,CAAC,GAAG,CACP,KAAa,EACb,IAAgD;QAEhD,MAAM,GAAG,GAAG,SAAS,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACzE,8EAA8E;QAC9E,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC;YACf,GAAG;YACH,YAAY,EAAE,IAAI,CAAC,WAAW;YAC9B,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,IAAI;YAC/B,KAAK;YACL,UAAU,EAAE,KAAK,CAAC,MAAM;YACxB,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;SACrC,CAAC,CAAC;QACH,OAAO,GAAG,CAAC;IACb,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,cAAc,CAAC,GAAG,CAAC,CAAC;QACpB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAkC,CAAC;QACnE,IAAI,CAAC,GAAG;YAAE,MAAM,IAAI,KAAK,CAAC,yBAAyB,GAAG,EAAE,CAAC,CAAC;QAC1D,OAAO,GAAG,CAAC,KAAK,CAAC;IACnB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,cAAc,CAAC,GAAG,CAAC,CAAC;QACpB,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACxB,CAAC;CACF;AAED,sFAAsF;AACtF,SAAS,cAAc,CAAC,GAAW;IACjC,IAAI,CAAC,uBAAuB,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,KAAK,CAAC,2BAA2B,GAAG,EAAE,CAAC,CAAC;IACpD,CAAC;AACH,CAAC"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Bidirectional mapping between RFC 5322 email and agent-inbox Messages.
3
+ *
4
+ * Parallels src/mesh/type-mapper.ts. Two pure functions:
5
+ * - inboundMailToMessage: external email → inbox Message
6
+ * - messageToOutboundMail: inbox Message → rendered OutboundMail
7
+ *
8
+ * Threading: RFC Message-ID / In-Reply-To / References map onto the inbox's
9
+ * thread_tag + in_reply_to. The RFC Message-ID is preserved in metadata so a
10
+ * later reply can reconstruct the In-Reply-To / References chain. See §5.
11
+ */
12
+ import type { Message } from "../types.js";
13
+ import type { AddressBook } from "./address-book.js";
14
+ import type { InboundMail, OutboundMail } from "./types.js";
15
+ export interface InboundMapContext {
16
+ addressBook: AddressBook;
17
+ /** Scope assigned when plus-addressing does not specify one. */
18
+ defaultScope?: string;
19
+ /**
20
+ * Resolve an RFC Message-ID we have already stored to its inbox Message id,
21
+ * so an inbound reply can set `in_reply_to` to the real parent.
22
+ */
23
+ lookupInboxIdByRfcMessageId?: (rfcMessageId: string) => string | undefined;
24
+ }
25
+ export interface OutboundMapContext {
26
+ addressBook: AddressBook;
27
+ /** Domain used to mint Message-IDs. Defaults to the address book primary domain. */
28
+ messageIdDomain?: string;
29
+ /** The RFC Message-ID we recorded when the parent inbox message was sent/received. */
30
+ lookupRfcMessageId?: (inboxId: string) => string | undefined;
31
+ /** The References chain recorded for the parent inbox message. */
32
+ lookupReferences?: (inboxId: string) => string[] | undefined;
33
+ }
34
+ /** Thrown when no inbound recipient resolves to a local agent. */
35
+ export declare class UnknownRecipientError extends Error {
36
+ readonly recipients: string[];
37
+ constructor(recipients: string[]);
38
+ }
39
+ export declare function inboundMailToMessage(mail: InboundMail, ctx: InboundMapContext): Message;
40
+ export declare function messageToOutboundMail(msg: Message, ctx: OutboundMapContext): OutboundMail;
41
+ //# sourceMappingURL=email-mapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-mapper.d.ts","sourceRoot":"","sources":["../../src/mail/email-mapper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAwC,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EAGb,MAAM,YAAY,CAAC;AAMpB,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,WAAW,CAAC;IACzB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;OAGG;IACH,2BAA2B,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC5E;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,WAAW,CAAC;IACzB,oFAAoF;IACpF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,sFAAsF;IACtF,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC7D,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,EAAE,GAAG,SAAS,CAAC;CAC9D;AAED,kEAAkE;AAClE,qBAAa,qBAAsB,SAAQ,KAAK;aAClB,UAAU,EAAE,MAAM,EAAE;gBAApB,UAAU,EAAE,MAAM,EAAE;CAMjD;AAMD,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,iBAAiB,GACrB,OAAO,CAkET;AAMD,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,kBAAkB,GACtB,YAAY,CAyCd"}
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Bidirectional mapping between RFC 5322 email and agent-inbox Messages.
3
+ *
4
+ * Parallels src/mesh/type-mapper.ts. Two pure functions:
5
+ * - inboundMailToMessage: external email → inbox Message
6
+ * - messageToOutboundMail: inbox Message → rendered OutboundMail
7
+ *
8
+ * Threading: RFC Message-ID / In-Reply-To / References map onto the inbox's
9
+ * thread_tag + in_reply_to. The RFC Message-ID is preserved in metadata so a
10
+ * later reply can reconstruct the In-Reply-To / References chain. See §5.
11
+ */
12
+ import { ulid } from "ulid";
13
+ import { createHash } from "node:crypto";
14
+ import { normalizeContent } from "../router/message-router.js";
15
+ /** Thrown when no inbound recipient resolves to a local agent. */
16
+ export class UnknownRecipientError extends Error {
17
+ recipients;
18
+ constructor(recipients) {
19
+ super(`No inbound recipient resolved to a local agent: ${recipients.join(", ")}`);
20
+ this.recipients = recipients;
21
+ this.name = "UnknownRecipientError";
22
+ }
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Inbound: email → Message
26
+ // ---------------------------------------------------------------------------
27
+ export function inboundMailToMessage(mail, ctx) {
28
+ const recipients = [];
29
+ const unresolved = [];
30
+ let scope;
31
+ const addKind = (addrs, kind) => {
32
+ for (const a of addrs ?? []) {
33
+ const resolved = ctx.addressBook.resolveInbound(a.address);
34
+ if (!resolved) {
35
+ unresolved.push(a.address);
36
+ continue;
37
+ }
38
+ recipients.push({ agent_id: resolved.agentId, kind });
39
+ // First resolved recipient with a plus-address scope wins. "to" is
40
+ // processed before "cc", so a to-scope still takes precedence, but a
41
+ // cc-only scoped recipient is no longer silently dropped.
42
+ if (scope === undefined && resolved.scope) {
43
+ scope = resolved.scope;
44
+ }
45
+ }
46
+ };
47
+ addKind(mail.to, "to");
48
+ addKind(mail.cc, "cc");
49
+ if (recipients.length === 0) {
50
+ throw new UnknownRecipientError(unresolved);
51
+ }
52
+ const rfcMessageId = mail.headers.messageId;
53
+ const rfcInReplyTo = mail.headers.inReplyTo;
54
+ const rfcReferences = mail.headers.references;
55
+ const metadata = {};
56
+ if (rfcMessageId)
57
+ metadata.rfcMessageId = rfcMessageId;
58
+ if (rfcInReplyTo)
59
+ metadata.rfcInReplyTo = rfcInReplyTo;
60
+ if (rfcReferences)
61
+ metadata.rfcReferences = rfcReferences;
62
+ if (mail.html)
63
+ metadata.htmlBody = mail.html;
64
+ if (mail.authResults)
65
+ metadata.authResults = mail.authResults;
66
+ if (mail.remote)
67
+ metadata.remote = mail.remote;
68
+ if (mail.envelopeFrom)
69
+ metadata.envelopeFrom = mail.envelopeFrom;
70
+ if (mail.bounce)
71
+ metadata.bounce = mail.bounce;
72
+ if (mail.attachments?.length)
73
+ metadata.mailAttachments = mail.attachments;
74
+ // Resolve in_reply_to to a real inbox id when we can; otherwise leave it unset
75
+ // (the RFC id is retained in metadata for later correlation).
76
+ let inReplyTo;
77
+ if (rfcInReplyTo && ctx.lookupInboxIdByRfcMessageId) {
78
+ inReplyTo = ctx.lookupInboxIdByRfcMessageId(rfcInReplyTo);
79
+ }
80
+ const text = mail.text ?? "";
81
+ return {
82
+ id: ulid(),
83
+ scope: scope ?? ctx.defaultScope ?? "default",
84
+ sender_id: mail.from.address,
85
+ recipients,
86
+ subject: mail.subject,
87
+ content: normalizeContent(text),
88
+ thread_tag: deriveThreadTag(rfcReferences, rfcInReplyTo, rfcMessageId),
89
+ in_reply_to: inReplyTo,
90
+ importance: importanceFromHeaders(mail.headers.raw),
91
+ metadata,
92
+ created_at: mail.receivedAt,
93
+ };
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Outbound: Message → email
97
+ // ---------------------------------------------------------------------------
98
+ export function messageToOutboundMail(msg, ctx) {
99
+ const domain = ctx.messageIdDomain ?? ctx.addressBook.primaryDomain();
100
+ const to = recipientsOfKind(msg, "to");
101
+ const cc = recipientsOfKind(msg, "cc");
102
+ const bcc = recipientsOfKind(msg, "bcc");
103
+ const { inReplyTo, references } = buildThreadingHeaders(msg, ctx);
104
+ const text = textFromContent(msg);
105
+ const html = typeof msg.metadata.htmlBody === "string"
106
+ ? msg.metadata.htmlBody
107
+ : undefined;
108
+ const attachments = validateAttachments(msg.metadata.mailAttachments);
109
+ // Only forward an explicit, opt-in hints object to the backend. We do NOT
110
+ // dump arbitrary internal message metadata to the external mail provider.
111
+ const backendHints = msg.metadata.mailBackendHints &&
112
+ typeof msg.metadata.mailBackendHints === "object"
113
+ ? msg.metadata.mailBackendHints
114
+ : undefined;
115
+ return {
116
+ idempotencyKey: msg.id,
117
+ from: ctx.addressBook.resolveFrom(msg.sender_id),
118
+ to,
119
+ ...(cc.length ? { cc } : {}),
120
+ ...(bcc.length ? { bcc } : {}),
121
+ subject: msg.subject,
122
+ text,
123
+ ...(html ? { html } : {}),
124
+ headers: {
125
+ messageId: mintMessageId(msg.id, domain),
126
+ ...(inReplyTo ? { inReplyTo } : {}),
127
+ ...(references && references.length ? { references } : {}),
128
+ },
129
+ ...(attachments ? { attachments } : {}),
130
+ ...(backendHints ? { backendHints } : {}),
131
+ };
132
+ }
133
+ /** Validate untrusted attachment metadata, dropping malformed entries. */
134
+ function validateAttachments(value) {
135
+ if (!Array.isArray(value))
136
+ return undefined;
137
+ const out = [];
138
+ for (const a of value) {
139
+ if (a &&
140
+ typeof a === "object" &&
141
+ typeof a.contentRef === "string" &&
142
+ typeof a.contentType === "string") {
143
+ const att = a;
144
+ out.push({
145
+ contentRef: att.contentRef,
146
+ contentType: att.contentType,
147
+ sizeBytes: typeof att.sizeBytes === "number" ? att.sizeBytes : 0,
148
+ ...(typeof att.filename === "string" ? { filename: att.filename } : {}),
149
+ ...(typeof att.contentId === "string" ? { contentId: att.contentId } : {}),
150
+ });
151
+ }
152
+ }
153
+ return out.length ? out : undefined;
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Helpers
157
+ // ---------------------------------------------------------------------------
158
+ function recipientsOfKind(msg, kind) {
159
+ return msg.recipients
160
+ .filter((r) => r.kind === kind)
161
+ .map((r) => ({ address: r.agent_id }));
162
+ }
163
+ /** Stable thread tag from the root of the References chain. */
164
+ function deriveThreadTag(references, inReplyTo, messageId) {
165
+ const root = (references && references.length > 0 ? references[0] : undefined) ??
166
+ inReplyTo ??
167
+ messageId;
168
+ if (!root)
169
+ return undefined;
170
+ const hash = createHash("sha1").update(root).digest("hex").slice(0, 12);
171
+ return `mail-${hash}`;
172
+ }
173
+ function buildThreadingHeaders(msg, ctx) {
174
+ if (!msg.in_reply_to) {
175
+ // Fresh thread, but a recorded RFC References chain (e.g. from the inbound
176
+ // side) should still be carried if present in metadata.
177
+ const meta = msg.metadata.rfcReferences;
178
+ return { references: Array.isArray(meta) ? meta : undefined };
179
+ }
180
+ const domain = ctx.messageIdDomain ?? ctx.addressBook.primaryDomain();
181
+ const parentRfcId = ctx.lookupRfcMessageId?.(msg.in_reply_to) ??
182
+ mintMessageId(msg.in_reply_to, domain);
183
+ const parentRefs = ctx.lookupReferences?.(msg.in_reply_to) ??
184
+ (Array.isArray(msg.metadata.rfcReferences)
185
+ ? msg.metadata.rfcReferences
186
+ : []);
187
+ return {
188
+ inReplyTo: parentRfcId,
189
+ references: [...parentRefs, parentRfcId],
190
+ };
191
+ }
192
+ function mintMessageId(inboxId, domain) {
193
+ return `<${inboxId}@${domain}>`;
194
+ }
195
+ function textFromContent(msg) {
196
+ const c = msg.content;
197
+ if (c.type === "text" && typeof c.text === "string")
198
+ return c.text;
199
+ return JSON.stringify(c);
200
+ }
201
+ function importanceFromHeaders(raw) {
202
+ const header = (k) => {
203
+ const v = raw[k];
204
+ return (Array.isArray(v) ? v[0] : v ?? "").toString().toLowerCase();
205
+ };
206
+ const importance = header("importance");
207
+ const priority = header("x-priority");
208
+ if (importance === "high" || priority.startsWith("1") || priority.startsWith("2")) {
209
+ return "high";
210
+ }
211
+ if (importance === "low" || priority.startsWith("4") || priority.startsWith("5")) {
212
+ return "low";
213
+ }
214
+ return "normal";
215
+ }
216
+ //# sourceMappingURL=email-mapper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-mapper.js","sourceRoot":"","sources":["../../src/mail/email-mapper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAmC/D,kEAAkE;AAClE,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IAClB;IAA5B,YAA4B,UAAoB;QAC9C,KAAK,CACH,mDAAmD,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAC3E,CAAC;QAHwB,eAAU,GAAV,UAAU,CAAU;QAI9C,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED,8EAA8E;AAC9E,2BAA2B;AAC3B,8EAA8E;AAE9E,MAAM,UAAU,oBAAoB,CAClC,IAAiB,EACjB,GAAsB;IAEtB,MAAM,UAAU,GAAgB,EAAE,CAAC;IACnC,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,IAAI,KAAyB,CAAC;IAE9B,MAAM,OAAO,GAAG,CAAC,KAAgC,EAAE,IAAmB,EAAE,EAAE;QACxE,KAAK,MAAM,CAAC,IAAI,KAAK,IAAI,EAAE,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,GAAG,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;YAC3D,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;gBAC3B,SAAS;YACX,CAAC;YACD,UAAU,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;YACtD,mEAAmE;YACnE,qEAAqE;YACrE,0DAA0D;YAC1D,IAAI,KAAK,KAAK,SAAS,IAAI,QAAQ,CAAC,KAAK,EAAE,CAAC;gBAC1C,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC;YACzB,CAAC;QACH,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;IACvB,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;IAEvB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,qBAAqB,CAAC,UAAU,CAAC,CAAC;IAC9C,CAAC;IAED,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;IAC5C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;IAC5C,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC;IAE9C,MAAM,QAAQ,GAA4B,EAAE,CAAC;IAC7C,IAAI,YAAY;QAAE,QAAQ,CAAC,YAAY,GAAG,YAAY,CAAC;IACvD,IAAI,YAAY;QAAE,QAAQ,CAAC,YAAY,GAAG,YAAY,CAAC;IACvD,IAAI,aAAa;QAAE,QAAQ,CAAC,aAAa,GAAG,aAAa,CAAC;IAC1D,IAAI,IAAI,CAAC,IAAI;QAAE,QAAQ,CAAC,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC;IAC7C,IAAI,IAAI,CAAC,WAAW;QAAE,QAAQ,CAAC,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC;IAC9D,IAAI,IAAI,CAAC,MAAM;QAAE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC/C,IAAI,IAAI,CAAC,YAAY;QAAE,QAAQ,CAAC,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC;IACjE,IAAI,IAAI,CAAC,MAAM;QAAE,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;IAC/C,IAAI,IAAI,CAAC,WAAW,EAAE,MAAM;QAAE,QAAQ,CAAC,eAAe,GAAG,IAAI,CAAC,WAAW,CAAC;IAE1E,+EAA+E;IAC/E,8DAA8D;IAC9D,IAAI,SAA6B,CAAC;IAClC,IAAI,YAAY,IAAI,GAAG,CAAC,2BAA2B,EAAE,CAAC;QACpD,SAAS,GAAG,GAAG,CAAC,2BAA2B,CAAC,YAAY,CAAC,CAAC;IAC5D,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;IAE7B,OAAO;QACL,EAAE,EAAE,IAAI,EAAE;QACV,KAAK,EAAE,KAAK,IAAI,GAAG,CAAC,YAAY,IAAI,SAAS;QAC7C,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO;QAC5B,UAAU;QACV,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,OAAO,EAAE,gBAAgB,CAAC,IAAI,CAAC;QAC/B,UAAU,EAAE,eAAe,CAAC,aAAa,EAAE,YAAY,EAAE,YAAY,CAAC;QACtE,WAAW,EAAE,SAAS;QACtB,UAAU,EAAE,qBAAqB,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC;QACnD,QAAQ;QACR,UAAU,EAAE,IAAI,CAAC,UAAU;KAC5B,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,4BAA4B;AAC5B,8EAA8E;AAE9E,MAAM,UAAU,qBAAqB,CACnC,GAAY,EACZ,GAAuB;IAEvB,MAAM,MAAM,GAAG,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,WAAW,CAAC,aAAa,EAAE,CAAC;IAEtE,MAAM,EAAE,GAAG,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACvC,MAAM,EAAE,GAAG,gBAAgB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IACvC,MAAM,GAAG,GAAG,gBAAgB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAEzC,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,qBAAqB,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IAElE,MAAM,IAAI,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IAClC,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,QAAQ,CAAC,QAAQ,KAAK,QAAQ;QACpD,CAAC,CAAE,GAAG,CAAC,QAAQ,CAAC,QAAmB;QACnC,CAAC,CAAC,SAAS,CAAC;IAEd,MAAM,WAAW,GAAG,mBAAmB,CAAC,GAAG,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAC;IAEtE,0EAA0E;IAC1E,0EAA0E;IAC1E,MAAM,YAAY,GAChB,GAAG,CAAC,QAAQ,CAAC,gBAAgB;QAC7B,OAAO,GAAG,CAAC,QAAQ,CAAC,gBAAgB,KAAK,QAAQ;QAC/C,CAAC,CAAE,GAAG,CAAC,QAAQ,CAAC,gBAA4C;QAC5D,CAAC,CAAC,SAAS,CAAC;IAEhB,OAAO;QACL,cAAc,EAAE,GAAG,CAAC,EAAE;QACtB,IAAI,EAAE,GAAG,CAAC,WAAW,CAAC,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC;QAChD,EAAE;QACF,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5B,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9B,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,IAAI;QACJ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE;YACP,SAAS,EAAE,aAAa,CAAC,GAAG,CAAC,EAAE,EAAE,MAAM,CAAC;YACxC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnC,GAAG,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3D;QACD,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC1C,CAAC;AACJ,CAAC;AAED,0EAA0E;AAC1E,SAAS,mBAAmB,CAAC,KAAc;IACzC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IAC5C,MAAM,GAAG,GAAqB,EAAE,CAAC;IACjC,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;QACtB,IACE,CAAC;YACD,OAAO,CAAC,KAAK,QAAQ;YACrB,OAAQ,CAAoB,CAAC,UAAU,KAAK,QAAQ;YACpD,OAAQ,CAAoB,CAAC,WAAW,KAAK,QAAQ,EACrD,CAAC;YACD,MAAM,GAAG,GAAG,CAAmB,CAAC;YAChC,GAAG,CAAC,IAAI,CAAC;gBACP,UAAU,EAAE,GAAG,CAAC,UAAU;gBAC1B,WAAW,EAAE,GAAG,CAAC,WAAW;gBAC5B,SAAS,EAAE,OAAO,GAAG,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;gBAChE,GAAG,CAAC,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvE,GAAG,CAAC,OAAO,GAAG,CAAC,SAAS,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAC3E,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;AACtC,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,SAAS,gBAAgB,CAAC,GAAY,EAAE,IAAmB;IACzD,OAAO,GAAG,CAAC,UAAU;SAClB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC;SAC9B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;AAC3C,CAAC;AAED,+DAA+D;AAC/D,SAAS,eAAe,CACtB,UAAgC,EAChC,SAA6B,EAC7B,SAA6B;IAE7B,MAAM,IAAI,GACR,CAAC,UAAU,IAAI,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QACjE,SAAS;QACT,SAAS,CAAC;IACZ,IAAI,CAAC,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5B,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACxE,OAAO,QAAQ,IAAI,EAAE,CAAC;AACxB,CAAC;AAED,SAAS,qBAAqB,CAC5B,GAAY,EACZ,GAAuB;IAEvB,IAAI,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC;QACrB,2EAA2E;QAC3E,wDAAwD;QACxD,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC;QACxC,OAAO,EAAE,UAAU,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAE,IAAiB,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;IAC9E,CAAC;IAED,MAAM,MAAM,GAAG,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,WAAW,CAAC,aAAa,EAAE,CAAC;IACtE,MAAM,WAAW,GACf,GAAG,CAAC,kBAAkB,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC;QACzC,aAAa,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;IAEzC,MAAM,UAAU,GACd,GAAG,CAAC,gBAAgB,EAAE,CAAC,GAAG,CAAC,WAAW,CAAC;QACvC,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,aAAa,CAAC;YACxC,CAAC,CAAE,GAAG,CAAC,QAAQ,CAAC,aAA0B;YAC1C,CAAC,CAAC,EAAE,CAAC,CAAC;IAEV,OAAO;QACL,SAAS,EAAE,WAAW;QACtB,UAAU,EAAE,CAAC,GAAG,UAAU,EAAE,WAAW,CAAC;KACzC,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,OAAe,EAAE,MAAc;IACpD,OAAO,IAAI,OAAO,IAAI,MAAM,GAAG,CAAC;AAClC,CAAC;AAED,SAAS,eAAe,CAAC,GAAY;IACnC,MAAM,CAAC,GAAG,GAAG,CAAC,OAAO,CAAC;IACtB,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,IAAI,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC,IAAI,CAAC;IACnE,OAAO,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC;AAED,SAAS,qBAAqB,CAC5B,GAAsC;IAEtC,MAAM,MAAM,GAAG,CAAC,CAAS,EAAU,EAAE;QACnC,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE,CAAC,WAAW,EAAE,CAAC;IACtE,CAAC,CAAC;IACF,MAAM,UAAU,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;IACxC,MAAM,QAAQ,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;IACtC,IAAI,UAAU,KAAK,MAAM,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAClF,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,IAAI,UAAU,KAAK,KAAK,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACjF,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Filesystem-backed AttachmentStore — opt-in for large/high-volume deployments.
3
+ *
4
+ * Keeps attachment bytes out of the SQLite DB (no WAL bloat, no synchronous
5
+ * blob I/O blocking the event loop). Content-addressed by sha256, so identical
6
+ * bytes dedup. Files live at `<dir>/<aa>/<sha256>` (sharded by the first byte
7
+ * to avoid one enormous directory).
8
+ *
9
+ * Durability: writes go to a unique temp file, are fsync'd, then atomically
10
+ * renamed into place. Because the caller stores the attachment (this `put`)
11
+ * BEFORE committing the message row that references it, a crash can only ever
12
+ * leave an orphan file (reclaimable by GC) — never a row pointing at missing
13
+ * bytes. See docs/MAIL-INTEROP-PLAN.md §4c.
14
+ */
15
+ import type { AttachmentStore } from "./types.js";
16
+ export declare class FsAttachmentStore implements AttachmentStore {
17
+ private dir;
18
+ constructor(dir: string);
19
+ put(bytes: Buffer, _meta: {
20
+ contentType: string;
21
+ filename?: string;
22
+ }): Promise<string>;
23
+ get(ref: string): Promise<Buffer>;
24
+ delete(ref: string): Promise<void>;
25
+ /**
26
+ * Garbage-collect orphan files: delete any stored attachment whose ref is not
27
+ * in `liveRefs` AND that is older than `minAgeMs` (default 1h).
28
+ *
29
+ * The age guard prevents a TOCTOU race: an attachment is `put` (file lands on
30
+ * disk) before the message row that references it is committed, so a just-
31
+ * written file may legitimately not appear in a `liveRefs` snapshot yet.
32
+ * Skipping recently-written files avoids deleting such in-flight attachments.
33
+ */
34
+ gc(liveRefs: Set<string>, minAgeMs?: number): Promise<number>;
35
+ private pathForHex;
36
+ private pathForRef;
37
+ }
38
+ //# sourceMappingURL=fs-attachment-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-attachment-store.d.ts","sourceRoot":"","sources":["../../src/mail/fs-attachment-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlD,qBAAa,iBAAkB,YAAW,eAAe;IAC3C,OAAO,CAAC,GAAG;gBAAH,GAAG,EAAE,MAAM;IAEzB,GAAG,CACP,KAAK,EAAE,MAAM,EACb,KAAK,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAChD,OAAO,CAAC,MAAM,CAAC;IAoCZ,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAYjC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC;;;;;;;;OAQG;IACG,EAAE,CAAC,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,QAAQ,GAAE,MAAkB,GAAG,OAAO,CAAC,MAAM,CAAC;IAoC9E,OAAO,CAAC,UAAU;IAKlB,OAAO,CAAC,UAAU;CASnB"}
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Filesystem-backed AttachmentStore — opt-in for large/high-volume deployments.
3
+ *
4
+ * Keeps attachment bytes out of the SQLite DB (no WAL bloat, no synchronous
5
+ * blob I/O blocking the event loop). Content-addressed by sha256, so identical
6
+ * bytes dedup. Files live at `<dir>/<aa>/<sha256>` (sharded by the first byte
7
+ * to avoid one enormous directory).
8
+ *
9
+ * Durability: writes go to a unique temp file, are fsync'd, then atomically
10
+ * renamed into place. Because the caller stores the attachment (this `put`)
11
+ * BEFORE committing the message row that references it, a crash can only ever
12
+ * leave an orphan file (reclaimable by GC) — never a row pointing at missing
13
+ * bytes. See docs/MAIL-INTEROP-PLAN.md §4c.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import * as fs from "node:fs/promises";
17
+ import * as path from "node:path";
18
+ const REF_RE = /^sha256:([0-9a-f]{64})$/;
19
+ export class FsAttachmentStore {
20
+ dir;
21
+ constructor(dir) {
22
+ this.dir = dir;
23
+ }
24
+ async put(bytes, _meta) {
25
+ const hex = createHash("sha256").update(bytes).digest("hex");
26
+ const ref = `sha256:${hex}`;
27
+ const full = this.pathForHex(hex);
28
+ // Content-addressed: if the bytes are already stored, we're done.
29
+ if (await exists(full))
30
+ return ref;
31
+ await fs.mkdir(path.dirname(full), { recursive: true });
32
+ // Atomic durable write: unique temp file → fsync → rename into place.
33
+ const tmp = `${full}.tmp-${process.pid}-${Date.now()}-${Math.random()
34
+ .toString(36)
35
+ .slice(2)}`;
36
+ let handle;
37
+ try {
38
+ handle = await fs.open(tmp, "wx");
39
+ await handle.writeFile(bytes);
40
+ await handle.sync();
41
+ }
42
+ finally {
43
+ await handle?.close();
44
+ }
45
+ try {
46
+ await fs.rename(tmp, full);
47
+ }
48
+ catch (err) {
49
+ // A concurrent put of identical bytes may have won the race — that's fine
50
+ // (same content). Clean up our temp file and return the ref.
51
+ await fs.rm(tmp, { force: true });
52
+ if (!(await exists(full)))
53
+ throw err;
54
+ }
55
+ // Durability: fsync the containing directory so the rename survives a crash
56
+ // (atomic rename alone is not crash-durable without a parent-dir fsync).
57
+ await fsyncDir(path.dirname(full));
58
+ return ref;
59
+ }
60
+ async get(ref) {
61
+ const full = this.pathForRef(ref);
62
+ try {
63
+ return await fs.readFile(full);
64
+ }
65
+ catch (err) {
66
+ if (err.code === "ENOENT") {
67
+ throw new Error(`Attachment not found: ${ref}`);
68
+ }
69
+ throw err;
70
+ }
71
+ }
72
+ async delete(ref) {
73
+ await fs.rm(this.pathForRef(ref), { force: true });
74
+ }
75
+ /**
76
+ * Garbage-collect orphan files: delete any stored attachment whose ref is not
77
+ * in `liveRefs` AND that is older than `minAgeMs` (default 1h).
78
+ *
79
+ * The age guard prevents a TOCTOU race: an attachment is `put` (file lands on
80
+ * disk) before the message row that references it is committed, so a just-
81
+ * written file may legitimately not appear in a `liveRefs` snapshot yet.
82
+ * Skipping recently-written files avoids deleting such in-flight attachments.
83
+ */
84
+ async gc(liveRefs, minAgeMs = 3_600_000) {
85
+ let removed = 0;
86
+ const cutoff = Date.now() - minAgeMs;
87
+ let shards;
88
+ try {
89
+ shards = await fs.readdir(this.dir, { withFileTypes: true });
90
+ }
91
+ catch (err) {
92
+ if (err.code === "ENOENT")
93
+ return 0;
94
+ throw err;
95
+ }
96
+ for (const shard of shards) {
97
+ if (!shard.isDirectory())
98
+ continue; // ignore stray files at the root
99
+ const shardDir = path.join(this.dir, shard.name);
100
+ let files;
101
+ try {
102
+ files = await fs.readdir(shardDir);
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ for (const name of files) {
108
+ if (name.includes(".tmp-"))
109
+ continue; // leave in-flight temp files
110
+ if (liveRefs.has(`sha256:${name}`))
111
+ continue;
112
+ const filePath = path.join(shardDir, name);
113
+ try {
114
+ const st = await fs.stat(filePath);
115
+ if (st.mtimeMs > cutoff)
116
+ continue; // too new — may be mid-ingestion
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ await fs.rm(filePath, { force: true });
122
+ removed++;
123
+ }
124
+ }
125
+ return removed;
126
+ }
127
+ pathForHex(hex) {
128
+ // Shard by the first byte of the hash to bound directory fan-out.
129
+ return path.join(this.dir, hex.slice(0, 2), hex);
130
+ }
131
+ pathForRef(ref) {
132
+ const m = REF_RE.exec(ref);
133
+ if (!m) {
134
+ // Strict validation prevents path traversal via a crafted ref (refs are
135
+ // always sha256-of-content, never caller-chosen).
136
+ throw new Error(`Invalid attachment ref: ${ref}`);
137
+ }
138
+ return this.pathForHex(m[1]);
139
+ }
140
+ }
141
+ async function exists(p) {
142
+ try {
143
+ await fs.access(p);
144
+ return true;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
150
+ /** fsync a directory so a rename into it is durable. Best-effort across platforms. */
151
+ async function fsyncDir(dir) {
152
+ let handle;
153
+ try {
154
+ handle = await fs.open(dir, "r");
155
+ await handle.sync();
156
+ }
157
+ catch {
158
+ // Some platforms (e.g. Windows) don't permit fsync on a directory handle.
159
+ // Atomic rename still holds; this only weakens the durability guarantee.
160
+ }
161
+ finally {
162
+ await handle?.close().catch(() => { });
163
+ }
164
+ }
165
+ //# sourceMappingURL=fs-attachment-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fs-attachment-store.js","sourceRoot":"","sources":["../../src/mail/fs-attachment-store.ts"],"names":[],"mappings":"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"}