@cosmicdrift/kumiko-samples 0.392.2 → 0.394.0

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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  133. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  134. package/packages/bundled-features/src/jobs/changes.json +18 -0
  135. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
  136. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  137. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  138. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  139. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  140. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  141. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  142. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  143. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  144. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  145. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  146. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  147. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  148. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  149. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  150. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  151. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  153. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
  155. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
  156. package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
  157. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  158. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  161. package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
  162. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  163. package/samples/apps/use-all-bundled/package.json +1 -0
  164. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  165. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  166. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  167. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  168. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  169. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
  170. package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
  171. package/samples/recipes/cross-feature-events/README.md +10 -0
  172. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  173. package/samples/recipes/dashboard-stats/README.md +8 -0
  174. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  175. package/samples/recipes/env-composition/README.md +2 -2
  176. package/samples/recipes/env-composition/src/env.ts +2 -2
  177. package/samples/recipes/inbound-mail-triage/README.md +12 -0
  178. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  179. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  180. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  181. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  182. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  183. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  184. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  185. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  186. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,119 @@
1
+ // Provider-agnostic hardening of a fetched message before it is dispatched to
2
+ // ingest-message. The ingest schema rejects oversized or empty fields; one such
3
+ // mail would otherwise block the cursor of its mailbox forever.
4
+
5
+ import type { RawInboundAttachment, RawInboundMessage, SkippedInboundAttachment } from "./types.ts";
6
+
7
+ export const INBOUND_MISSING_SENDER = "(no sender)";
8
+ export const INBOUND_BODY_TEXT_MAX_CHARS = 32_000;
9
+ export const INBOUND_BODY_HTML_MAX_CHARS = 5_000_000;
10
+ export const INBOUND_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024;
11
+ export const INBOUND_MAIL_ATTACHMENTS_MAX_BYTES = 50 * 1024 * 1024;
12
+ // The .eml holds every attachment again, so it shares the per-mail byte budget.
13
+ export const INBOUND_RAW_MIME_MAX_BYTES = INBOUND_MAIL_ATTACHMENTS_MAX_BYTES;
14
+ export const INBOUND_ATTACHMENTS_MAX_COUNT = 50;
15
+ export const INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT = 200;
16
+
17
+ const FROM_MAX = 2000;
18
+ const ADDRESS_MAX = 2000;
19
+ const ADDRESS_LIST_MAX = 200;
20
+ const REFERENCE_MAX = 500;
21
+ const REFERENCES_MAX = 200;
22
+ const SUBJECT_MAX = 4000;
23
+ const SNIPPET_MAX = 4000;
24
+ const MESSAGE_ID_HEADER_MAX = 500;
25
+
26
+ export type NormalizedInboundMessage = Omit<
27
+ RawInboundMessage,
28
+ "text" | "html" | "attachments" | "skippedAttachments"
29
+ > & {
30
+ readonly bodyText: string;
31
+ readonly bodyTextTruncated: boolean;
32
+ readonly bodyHtml: string | null;
33
+ readonly attachments: readonly RawInboundAttachment[];
34
+ readonly skippedAttachments: readonly SkippedInboundAttachment[];
35
+ };
36
+
37
+ function isHighSurrogate(code: number): boolean {
38
+ return code >= 0xd800 && code <= 0xdbff;
39
+ }
40
+
41
+ // Cuts at UTF-16 units like the zod max, but never leaves half a surrogate pair behind.
42
+ function clip(value: string, max: number): string {
43
+ if (value.length <= max) return value;
44
+ const cut = isHighSurrogate(value.charCodeAt(max - 1)) ? max - 1 : max;
45
+ return value.slice(0, cut);
46
+ }
47
+
48
+ function limitReferences(references: readonly string[]): readonly string[] {
49
+ const usable = references.filter((r) => r.length > 0 && r.length <= REFERENCE_MAX);
50
+ if (usable.length <= REFERENCES_MAX) return usable;
51
+ // The root keeps the thread anchor, the newest entries keep the reply chain.
52
+ return [...usable.slice(0, 1), ...usable.slice(usable.length - (REFERENCES_MAX - 1))];
53
+ }
54
+
55
+ type AttachmentSplit = {
56
+ readonly stored: readonly RawInboundAttachment[];
57
+ readonly skipped: readonly SkippedInboundAttachment[];
58
+ };
59
+
60
+ function splitAttachmentsByLimits(attachments: readonly RawInboundAttachment[]): AttachmentSplit {
61
+ const stored: RawInboundAttachment[] = [];
62
+ const skipped: SkippedInboundAttachment[] = [];
63
+ let totalBytes = 0;
64
+ for (const attachment of attachments) {
65
+ const size = attachment.data.byteLength;
66
+ if (size > INBOUND_ATTACHMENT_MAX_BYTES) {
67
+ skipped.push({ mimeType: attachment.mimeType, size, reason: "too_large" });
68
+ } else if (
69
+ totalBytes + size > INBOUND_MAIL_ATTACHMENTS_MAX_BYTES ||
70
+ stored.length >= INBOUND_ATTACHMENTS_MAX_COUNT
71
+ ) {
72
+ skipped.push({ mimeType: attachment.mimeType, size, reason: "mail_too_large" });
73
+ } else {
74
+ totalBytes += size;
75
+ stored.push({
76
+ fileName: clip(attachment.fileName, 255),
77
+ mimeType: clip(attachment.mimeType, 255),
78
+ data: attachment.data,
79
+ });
80
+ }
81
+ }
82
+ return { stored, skipped };
83
+ }
84
+
85
+ export function normalizeRawInboundMessage(msg: RawInboundMessage): NormalizedInboundMessage {
86
+ const { text, html, attachments, skippedAttachments, ...envelope } = msg;
87
+ const from = msg.from.trim();
88
+ const fullText = text ?? "";
89
+ const split = splitAttachmentsByLimits(attachments ?? []);
90
+ const bodyHtml = html ?? null;
91
+ const rawMimeTooLarge =
92
+ msg.rawMime !== null && msg.rawMime.byteLength > INBOUND_RAW_MIME_MAX_BYTES;
93
+ const skippedRawMime: readonly SkippedInboundAttachment[] =
94
+ rawMimeTooLarge && msg.rawMime !== null
95
+ ? [{ mimeType: "message/rfc822", size: msg.rawMime.byteLength, reason: "too_large" }]
96
+ : [];
97
+ return {
98
+ ...envelope,
99
+ rawMime: rawMimeTooLarge ? null : msg.rawMime,
100
+ messageIdHeader:
101
+ msg.messageIdHeader !== null && msg.messageIdHeader.length <= MESSAGE_ID_HEADER_MAX
102
+ ? msg.messageIdHeader
103
+ : null,
104
+ references: limitReferences(msg.references),
105
+ from: from.length === 0 ? INBOUND_MISSING_SENDER : clip(from, FROM_MAX),
106
+ to: msg.to.slice(0, ADDRESS_LIST_MAX).map((a) => clip(a, ADDRESS_MAX)),
107
+ cc: msg.cc.slice(0, ADDRESS_LIST_MAX).map((a) => clip(a, ADDRESS_MAX)),
108
+ subject: clip(msg.subject, SUBJECT_MAX),
109
+ snippet: clip(msg.snippet, SNIPPET_MAX),
110
+ bodyText: clip(fullText, INBOUND_BODY_TEXT_MAX_CHARS),
111
+ bodyTextTruncated: fullText.length > INBOUND_BODY_TEXT_MAX_CHARS,
112
+ bodyHtml: bodyHtml !== null && bodyHtml.length <= INBOUND_BODY_HTML_MAX_CHARS ? bodyHtml : null,
113
+ attachments: split.stored,
114
+ skippedAttachments: [...(skippedAttachments ?? []), ...split.skipped, ...skippedRawMime].slice(
115
+ 0,
116
+ INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT,
117
+ ),
118
+ };
119
+ }
@@ -24,13 +24,9 @@ import {
24
24
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
25
25
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
26
26
  import { InboundMailAuthMethods, inboundCredentialSecretKey } from "./constants.ts";
27
- import {
28
- InboundAuthError,
29
- type InboundMailContext,
30
- type InboundMailProviderPlugin,
31
- type MailAccountRecord,
32
- type OAuthTokenSet,
33
- } from "./types.ts";
27
+ import type { OAuthTokenSet } from "./inbound-oauth-flow.ts";
28
+ import { InboundAuthError, InboundCredentialNotStoredError } from "./provider-errors.ts";
29
+ import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
34
30
 
35
31
  // 'inoa' as ASCII — disjoint from the other fixed advisory-lock namespaces.
36
32
  const OAUTH_REFRESH_LOCK_NAMESPACE = 0x696e6f61;
@@ -148,7 +144,7 @@ export function createOAuthAccessTokenManager(
148
144
  const secrets = deps.secrets.withRunner(tx);
149
145
  const stored = await secrets.get(account.tenantId, secretKey);
150
146
  if (!stored) {
151
- throw new InboundAuthError("no refresh token stored for account");
147
+ throw new InboundCredentialNotStoredError("no refresh token stored for account");
152
148
  }
153
149
  const currentRefreshToken = stored.reveal();
154
150
  const tokens = await refreshWithTimeout(plugin, account, currentRefreshToken);
@@ -207,3 +203,25 @@ export function createOAuthAccessTokenManager(
207
203
  },
208
204
  };
209
205
  }
206
+
207
+ // Refresh token into the per-account secret slot (slot = accountId); access tokens (~1h) are never
208
+ // persisted. A shared manager writes under the account's refresh lock and primes its cache.
209
+ export async function storeOAuthCallbackCredential(
210
+ manager: OAuthAccessTokenManager | undefined,
211
+ secrets: SecretsContext,
212
+ account: Pick<MailAccountRecord, "id" | "tenantId">,
213
+ tokens: OAuthTokenSet,
214
+ refreshToken: string,
215
+ ): Promise<void> {
216
+ if (manager) {
217
+ await manager.storeRefreshToken(account, refreshToken);
218
+ manager.primeFromExchange(account, tokens);
219
+ } else {
220
+ await secrets.set(
221
+ account.tenantId,
222
+ inboundCredentialSecretKey(account.id),
223
+ refreshToken,
224
+ inboundRefreshTokenSecretOptions(account.id),
225
+ );
226
+ }
227
+ }
@@ -0,0 +1,28 @@
1
+ // Who may attach a stored file to an outgoing mail: exactly who may download it. Attachments of
2
+ // imported mails are bound to the mail, so the file guard answers with the mail's visibility.
3
+
4
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
5
+ import { isUuid } from "@cosmicdrift/kumiko-framework/engine";
6
+ import {
7
+ type CanReadFileRefContext,
8
+ type FileRef,
9
+ fileRefsTable,
10
+ } from "@cosmicdrift/kumiko-framework/files";
11
+ import type { ResolveOutboundAttachmentsContext } from "./outbound-attachments.ts";
12
+
13
+ export type OutboundAttachmentAccessContext = ResolveOutboundAttachmentsContext &
14
+ CanReadFileRefContext;
15
+
16
+ /** The file of the sender's tenant, or null: another tenant's and a deleted file read like a missing one. */
17
+ export async function loadAttachableFileRef(
18
+ ctx: OutboundAttachmentAccessContext,
19
+ fileRefId: string,
20
+ ): Promise<FileRef | null> {
21
+ if (!isUuid(fileRefId)) return null;
22
+ const [fileRef] = await selectMany<FileRef>(ctx.db, fileRefsTable, {
23
+ id: fileRefId,
24
+ tenantId: ctx.user.tenantId,
25
+ isDeleted: false,
26
+ });
27
+ return fileRef ?? null;
28
+ }
@@ -0,0 +1,111 @@
1
+ // Turns the file references of an outgoing mail into bytes. Only the sender's own tenant is
2
+ // searched, so another tenant's file reads exactly like a missing one; a file the caller may not
3
+ // read counts as missing too (outbound-attachment-access.ts).
4
+
5
+ import {
6
+ collectPiiSubjectFields,
7
+ configuredPiiSubjectKms,
8
+ decryptPiiFieldValues,
9
+ isPiiCiphertext,
10
+ PII_ERASED_SENTINEL,
11
+ } from "@cosmicdrift/kumiko-framework/crypto";
12
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { canCallerReadFileRef, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
14
+ import {
15
+ FileRefTooLargeError,
16
+ fileContextForTenant,
17
+ readFileRefContent,
18
+ } from "../file-foundation/index.ts";
19
+ import { loadAttachableFileRef } from "./outbound-attachment-access.ts";
20
+ import { OUTBOUND_ATTACHMENTS_MAX_BYTES, type OutboundAttachment } from "./outbound-mail.ts";
21
+
22
+ export type ResolveOutboundAttachmentsContext = Pick<
23
+ HandlerContext,
24
+ "db" | "user" | "services" | "obs" | "registry"
25
+ > & { readonly dispatch: Pick<HandlerContext["dispatch"], "query"> };
26
+
27
+ export type ResolvedOutboundAttachments =
28
+ | {
29
+ readonly isSuccess: true;
30
+ readonly attachments: readonly OutboundAttachment[];
31
+ readonly fileIds: readonly string[];
32
+ }
33
+ | { readonly isSuccess: false; readonly failure: "attachmentInvalid" | "attachmentsTooLarge" };
34
+
35
+ const FALLBACK_ATTACHMENT_NAME = "attachment";
36
+ const PII_SUBJECT_FIELDS = collectPiiSubjectFields(fileRefEntity);
37
+
38
+ // The stored name is personal data and may be ciphertext; a name that cannot be read must not
39
+ // stop the mail, and ciphertext or the erased marker never goes into a header.
40
+ export async function readableFileName(file: {
41
+ readonly id: string;
42
+ readonly fileName: string;
43
+ }): Promise<string> {
44
+ const storedName = file.fileName;
45
+ if (!isPiiCiphertext(storedName)) return storedName;
46
+ const kms = configuredPiiSubjectKms();
47
+ if (!kms) return FALLBACK_ATTACHMENT_NAME;
48
+ try {
49
+ const decrypted = await decryptPiiFieldValues(
50
+ { fileName: storedName },
51
+ PII_SUBJECT_FIELDS,
52
+ kms,
53
+ { requestId: `outbound-attachment:${file.id}` },
54
+ );
55
+ const name = decrypted["fileName"];
56
+ if (typeof name !== "string" || isPiiCiphertext(name) || name === PII_ERASED_SENTINEL) {
57
+ return FALLBACK_ATTACHMENT_NAME;
58
+ }
59
+ return name;
60
+ } catch {
61
+ // skip: broken crypto metadata must not block the mail; the attachment keeps a neutral name.
62
+ return FALLBACK_ATTACHMENT_NAME;
63
+ }
64
+ }
65
+
66
+ export async function resolveOutboundAttachments(
67
+ ctx: ResolveOutboundAttachmentsContext,
68
+ fileIds: readonly string[],
69
+ ): Promise<ResolvedOutboundAttachments> {
70
+ const uniqueIds = [...new Set(fileIds)];
71
+ const attachments: OutboundAttachment[] = [];
72
+ let remainingBytes = OUTBOUND_ATTACHMENTS_MAX_BYTES;
73
+ const tenantId = ctx.user.tenantId;
74
+ for (const fileRefId of uniqueIds) {
75
+ // A file the caller may not read answers like an unknown id, so ids cannot be probed.
76
+ const fileRef = await loadAttachableFileRef(ctx, fileRefId);
77
+ if (fileRef === null || !(await canCallerReadFileRef(ctx, fileRef))) {
78
+ return { isSuccess: false, failure: "attachmentInvalid" };
79
+ }
80
+ let found: Awaited<ReturnType<typeof readFileRefContent>>;
81
+ try {
82
+ found = await readFileRefContent({
83
+ db: ctx.db,
84
+ filesForTenant: (forTenantId) => fileContextForTenant(ctx, tenantId, forTenantId),
85
+ fileRefId,
86
+ allowedTenantIds: [tenantId],
87
+ maxBytes: remainingBytes,
88
+ });
89
+ } catch (err) {
90
+ if (err instanceof FileRefTooLargeError) {
91
+ return { isSuccess: false, failure: "attachmentsTooLarge" };
92
+ }
93
+ ctx.obs.log.warn("outbound attachment could not be read", {
94
+ fileRefId,
95
+ errorName: err instanceof Error ? err.name : typeof err,
96
+ });
97
+ return { isSuccess: false, failure: "attachmentInvalid" };
98
+ }
99
+ if (found === null) return { isSuccess: false, failure: "attachmentInvalid" };
100
+ if (found.content.byteLength > remainingBytes) {
101
+ return { isSuccess: false, failure: "attachmentsTooLarge" };
102
+ }
103
+ remainingBytes -= found.content.byteLength;
104
+ attachments.push({
105
+ fileName: await readableFileName({ id: fileRefId, fileName: found.fileName }),
106
+ mimeType: found.contentType,
107
+ data: found.content,
108
+ });
109
+ }
110
+ return { isSuccess: true, attachments, fileIds: uniqueIds };
111
+ }
@@ -5,6 +5,10 @@ import * as z from "zod";
5
5
 
6
6
  const MAX_RECIPIENTS = 50;
7
7
  const MAX_REFERENCES = 100;
8
+ const MAX_ATTACHMENTS = 10;
9
+
10
+ /** Raw bytes, not the encoded size: base64 grows them by a third, which stays under the usual 25 MB SMTP limit. */
11
+ export const OUTBOUND_ATTACHMENTS_MAX_BYTES = 18 * 1024 * 1024;
8
12
 
9
13
  // Message ids travel without angle brackets, like RawInboundMessage.references. Anything that
10
14
  // could close the bracket or start a new header is refused instead of being encoded.
@@ -26,17 +30,31 @@ export const outboundMailMessageSchema = z
26
30
  html: z.string().optional(),
27
31
  inReplyTo: messageIdSchema.optional(),
28
32
  references: z.array(messageIdSchema).max(MAX_REFERENCES).optional(),
33
+ attachmentFileIds: z.array(z.uuid()).max(MAX_ATTACHMENTS).optional(),
29
34
  })
30
35
  .refine((message) => message.text !== undefined || message.html !== undefined, {
31
36
  message: "needs text or html",
32
37
  });
33
38
  export type OutboundMailMessage = z.infer<typeof outboundMailMessageSchema>;
34
39
 
35
- export type OutboundMailSent = {
40
+ /** An attachment whose file reference was resolved to bytes before the provider sends. */
41
+ export type OutboundAttachment = {
42
+ readonly fileName: string;
43
+ readonly mimeType: string;
44
+ readonly data: Uint8Array;
45
+ };
46
+
47
+ /** What a provider plugin reports about a mail it sent. */
48
+ export type ProviderSentMail = {
36
49
  /** Message-ID header of the sent mail, without angle brackets. */
37
50
  readonly messageIdHeader: string;
38
51
  };
39
52
 
53
+ export type OutboundMailSent = ProviderSentMail & {
54
+ /** The file references that went out as attachments, for the caller's own record of the mail. */
55
+ readonly attachmentFileIds: readonly string[];
56
+ };
57
+
40
58
  // Same `kind` discriminant as the inbound error classes (see types.ts).
41
59
 
42
60
  /** The mailbox has no outgoing server stored, for example a credential document from before
@@ -9,9 +9,10 @@ import { type MailAccountRow, toMailAccountRecord } from "./account-record.ts";
9
9
  import { InboundMailAccountStatuses } from "./constants.ts";
10
10
  import { INBOUND_PROBE_KEYS } from "./probe-keys.ts";
11
11
  import { mailAccountsProjectionTable } from "./projection.ts";
12
+ import { isInboundAuthError } from "./provider-errors.ts";
12
13
  import { resolveInboundProviderForAccount } from "./provider-factory.ts";
13
14
  import { MAIL_ACCOUNT_LIST_SCREEN_QN } from "./screens.ts";
14
- import { type InboundMailContext, isInboundAuthError } from "./types.ts";
15
+ import type { InboundMailContext } from "./types.ts";
15
16
 
16
17
  const SHORT_ID_LENGTH = 8;
17
18
 
@@ -23,11 +23,13 @@ import {
23
23
  type EntityTableMeta,
24
24
  insertOnConflictDoNothing,
25
25
  requireEntityTableMeta,
26
+ updateMany,
26
27
  upsertOnConflict,
27
28
  } from "@cosmicdrift/kumiko-framework/db";
28
29
  import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
29
30
  import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.ts";
30
31
  import type {
32
+ InboundMessageContentFetchedEventPayload,
31
33
  InboundMessageEventPayload,
32
34
  MailAccountEventPayload,
33
35
  MailThreadEventPayload,
@@ -74,6 +76,8 @@ const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event
74
76
  address: p.address,
75
77
  status: p.status,
76
78
  watchState: p.watchState,
79
+ lastError: p.lastError,
80
+ trustedAuthservId: p.trustedAuthservId,
77
81
  };
78
82
  await upsertOnConflict(
79
83
  tx,
@@ -101,8 +105,8 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
101
105
 
102
106
  // =============================================================================
103
107
  // inbound-message — exactly one received event per stream (deterministic
104
- // aggregateId). INSERT-ignore makes replays no-ops on the primary key;
105
- // there is no update case.
108
+ // aggregateId). INSERT-ignore makes replays no-ops on the primary key; the
109
+ // only later change is the content-fetched event for snippet-only imports.
106
110
  // =============================================================================
107
111
 
108
112
  /** inbound-message-received → INSERT ... ON CONFLICT DO NOTHING. */
@@ -124,10 +128,37 @@ export const applyInboundMessageReceived = defineApply<InboundMessageEventPayloa
124
128
  receivedAt: p.receivedAtIso,
125
129
  bodyRef: p.bodyRef,
126
130
  scope: p.scope,
131
+ bodyText: p.bodyText,
132
+ bodyTextTruncated: p.bodyTextTruncated,
133
+ attachmentFileIds: p.attachmentFileIds,
134
+ skippedAttachments: JSON.stringify(p.skippedAttachments),
135
+ senderAuthentication: JSON.stringify(p.senderAuthentication),
136
+ contentState: p.contentState,
137
+ contentUnavailableReason: p.contentUnavailableReason,
127
138
  });
128
139
  },
129
140
  );
130
141
 
142
+ /** inbound-message-content-fetched → fills the content columns of the already projected row. */
143
+ export const applyInboundMessageContentFetched =
144
+ defineApply<InboundMessageContentFetchedEventPayload>(async (event, tx) => {
145
+ const p = event.payload;
146
+ await updateMany(
147
+ tx,
148
+ inboundMessagesProjectionMeta,
149
+ {
150
+ bodyRef: p.bodyRef,
151
+ bodyText: p.bodyText,
152
+ bodyTextTruncated: p.bodyTextTruncated,
153
+ attachmentFileIds: p.attachmentFileIds,
154
+ skippedAttachments: JSON.stringify(p.skippedAttachments),
155
+ contentState: p.contentState,
156
+ contentUnavailableReason: p.contentUnavailableReason,
157
+ },
158
+ { id: event.aggregateId },
159
+ );
160
+ });
161
+
131
162
  // =============================================================================
132
163
  // mail-thread — rollup per (tenantId, threadKey). The write handler computes
133
164
  // messageCount/lastMessageAt and appends the updated event with the NEW state.
@@ -0,0 +1,74 @@
1
+ // =============================================================================
2
+ // Typed errors handled uniformly by the Foundation during polling:
3
+ // InboundAuthError → status=auth_error, no retry
4
+ // InboundRateLimitError → abort poll, wait for the next cron tick
5
+ // InboundCursorInvalidError → reset cursor + full resync in the backfill
6
+ // window; deduplication handles duplicates
7
+ // InboundTransientError → job retry (retries: 3, exponential)
8
+ // =============================================================================
9
+ //
10
+ // Use a `kind` discriminant instead of instanceof: plugins and the Foundation
11
+ // may run in separate bundles (dual-package-hazard), and instanceof is
12
+ // unreliable across realm or package-copy boundaries.
13
+
14
+ export class InboundAuthError extends Error {
15
+ readonly kind = "inbound-auth-error" as const;
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = "InboundAuthError";
19
+ }
20
+ }
21
+
22
+ /** The credential secret is not there (yet). Still an auth error for a regular poll; a sync woken
23
+ * right after connect treats it as "not ready" because the secret is written after the account. */
24
+ export class InboundCredentialNotStoredError extends InboundAuthError {
25
+ readonly credentialNotStored = true as const;
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "InboundCredentialNotStoredError";
29
+ }
30
+ }
31
+
32
+ export class InboundRateLimitError extends Error {
33
+ readonly kind = "inbound-rate-limit-error" as const;
34
+ readonly retryAfterMs: number;
35
+ constructor(message: string, retryAfterMs: number) {
36
+ super(message);
37
+ this.name = "InboundRateLimitError";
38
+ this.retryAfterMs = retryAfterMs;
39
+ }
40
+ }
41
+
42
+ export class InboundCursorInvalidError extends Error {
43
+ readonly kind = "inbound-cursor-invalid-error" as const;
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "InboundCursorInvalidError";
47
+ }
48
+ }
49
+
50
+ export class InboundTransientError extends Error {
51
+ readonly kind = "inbound-transient-error" as const;
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = "InboundTransientError";
55
+ }
56
+ }
57
+
58
+ export function isInboundAuthError(e: unknown): e is InboundAuthError {
59
+ return e instanceof Error && "kind" in e && e.kind === "inbound-auth-error";
60
+ }
61
+ export function isInboundCredentialNotStoredError(
62
+ e: unknown,
63
+ ): e is InboundCredentialNotStoredError {
64
+ return isInboundAuthError(e) && "credentialNotStored" in e && e.credentialNotStored === true;
65
+ }
66
+ export function isInboundRateLimitError(e: unknown): e is InboundRateLimitError {
67
+ return e instanceof Error && "kind" in e && e.kind === "inbound-rate-limit-error";
68
+ }
69
+ export function isInboundCursorInvalidError(e: unknown): e is InboundCursorInvalidError {
70
+ return e instanceof Error && "kind" in e && e.kind === "inbound-cursor-invalid-error";
71
+ }
72
+ export function isInboundTransientError(e: unknown): e is InboundTransientError {
73
+ return e instanceof Error && "kind" in e && e.kind === "inbound-transient-error";
74
+ }
@@ -0,0 +1,56 @@
1
+ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ access,
4
+ createEntity,
5
+ createTextField,
6
+ createTimestampField,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+
9
+ export const QUARANTINE_REASON_CODES = ["unparseable", "invalid_message", "ingest_failed"] as const;
10
+ export type QuarantineReasonCode = (typeof QUARANTINE_REASON_CODES)[number];
11
+
12
+ /** A provider message the ingest cannot take. No subject or sender, so nothing here is personal data;
13
+ * the UID is enough for the operator to find the mail in the mailbox. */
14
+ export const quarantineEntity = createEntity({
15
+ table: "store_mail_quarantine",
16
+ softDelete: false,
17
+ indexes: [{ unique: true, columns: ["tenantId", "accountId", "providerMessageId"] }],
18
+ fields: {
19
+ accountId: createTextField({
20
+ required: true,
21
+ maxLength: 36,
22
+ access: { write: access.privileged },
23
+ personal: false,
24
+ reason: "technical_reference",
25
+ }),
26
+ providerMessageId: createTextField({
27
+ required: true,
28
+ maxLength: 500,
29
+ access: { write: access.privileged },
30
+ personal: false,
31
+ reason: "technical_reference",
32
+ }),
33
+ reasonCode: createTextField({
34
+ required: true,
35
+ maxLength: 30,
36
+ access: { write: access.privileged },
37
+ personal: false,
38
+ reason: "technical_reference",
39
+ }),
40
+ quarantinedAt: createTimestampField({
41
+ required: true,
42
+ access: { write: access.privileged },
43
+ }),
44
+ acknowledgedAt: createTimestampField({
45
+ access: { write: access.privileged },
46
+ }),
47
+ // Set by retry-quarantined-message; the next successful sync of the mailbox reloads the mail and clears the row.
48
+ retryRequestedAt: createTimestampField({
49
+ access: { write: access.privileged },
50
+ }),
51
+ },
52
+ });
53
+
54
+ export const quarantineTable = deriveEntityTableMeta("mail-quarantine", quarantineEntity, {
55
+ source: "unmanaged",
56
+ });
@@ -0,0 +1,63 @@
1
+ // A quarantined mail stays visible: its headers (and the original, when file storage is there) are
2
+ // imported as a message without content, marked "unavailable / quarantined". The import is best
3
+ // effort: the quarantine row already moves the cursor on, and a retry can still load the content.
4
+
5
+ import { InboundMailFoundationHandlers } from "./constants.ts";
6
+ import { ingestPayloadOf } from "./ingest-payload.ts";
7
+ import { INBOUND_MISSING_SENDER, normalizeRawInboundMessage } from "./normalize-inbound-message.ts";
8
+ import type { DispatchWrite } from "./supervisor-ingest.ts";
9
+ import type { MailAccountRecord, RawInboundMessage, RejectedInboundMessage } from "./types.ts";
10
+
11
+ const EPOCH_ISO = "1970-01-01T00:00:00Z";
12
+
13
+ export function headerOnlyOf(msg: RawInboundMessage): RawInboundMessage {
14
+ const { text: _text, html: _html, attachments: _a, skippedAttachments: _s, ...envelope } = msg;
15
+ return { ...envelope, text: "", html: null, attachments: [], skippedAttachments: [] };
16
+ }
17
+
18
+ /** Null when the provider reported no headers for the mail: nothing meaningful to show. */
19
+ export function headerOnlyOfRejected(entry: RejectedInboundMessage): RawInboundMessage | null {
20
+ const { headers } = entry;
21
+ if (!headers) return null;
22
+ return {
23
+ providerMessageId: entry.providerMessageId,
24
+ messageIdHeader: headers.messageIdHeader,
25
+ providerThreadId: null,
26
+ references: [],
27
+ from: headers.from === "" ? INBOUND_MISSING_SENDER : headers.from,
28
+ to: headers.to,
29
+ cc: headers.cc,
30
+ subject: headers.subject,
31
+ snippet: "",
32
+ receivedAtIso: headers.receivedAtIso === "" ? EPOCH_ISO : headers.receivedAtIso,
33
+ rawMime: entry.rawMime ?? null,
34
+ scope: "inbox",
35
+ };
36
+ }
37
+
38
+ export async function importQuarantinedHeaders(
39
+ deps: { readonly dispatchWrite: DispatchWrite; readonly log: (line: string) => void },
40
+ account: MailAccountRecord,
41
+ headerOnly: RawInboundMessage | null,
42
+ ): Promise<void> {
43
+ // skip: no header data to import for this mail
44
+ if (!headerOnly) return;
45
+ try {
46
+ const result = await deps.dispatchWrite({
47
+ handlerQn: InboundMailFoundationHandlers.ingestMessage,
48
+ tenantId: account.tenantId,
49
+ payload: ingestPayloadOf(account, normalizeRawInboundMessage(headerOnly), {
50
+ providerCursor: "quarantine",
51
+ bodyRef: "",
52
+ contentUnavailableReason: "quarantined",
53
+ }),
54
+ });
55
+ if (!result.isSuccess) {
56
+ deps.log(
57
+ `inbound-mail: account ${account.id} header import of a quarantined mail was rejected`,
58
+ );
59
+ }
60
+ } catch {
61
+ deps.log(`inbound-mail: account ${account.id} header import of a quarantined mail failed`);
62
+ }
63
+ }