@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
@@ -25,33 +25,43 @@
25
25
 
26
26
  import { mailAllowedPrivateHostsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
27
27
  import {
28
+ assertRolesMatchFoundation,
28
29
  INBOUND_MAIL_PROVIDER_EXTENSION,
29
30
  type InboundFetchResult,
30
31
  type InboundMailContext,
31
32
  type InboundMailProviderPlugin,
33
+ type InboundMailRoleOptions,
32
34
  type MailAccountRecord,
33
35
  type RawInboundMessage,
36
+ type RejectedInboundMessage,
37
+ resolveInboundMailRoles,
38
+ roleDedupeOptions,
34
39
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
35
40
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
36
41
  import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
37
42
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
38
43
  import type { ImapFlow, MailboxObject } from "imapflow";
39
- import { connectMailboxHandler } from "./connect-mailbox.ts";
44
+ import { createConnectMailboxHandler } from "./connect-mailbox.ts";
45
+ import { createConnectMyMailboxHandler } from "./connect-my-mailbox.ts";
40
46
  import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.ts";
41
47
  import { readCredentialDocument } from "./credential-document.ts";
48
+ import { downloadBatch } from "./download-batch.ts";
49
+ import { fetchMessageContent } from "./fetch-content.ts";
42
50
  import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.ts";
43
51
  import {
44
52
  assertUidValidity,
45
- coerceDate,
53
+ closeOnAbort,
46
54
  connectOnce,
47
55
  createImapClient,
48
56
  IMAP_MAILBOX,
49
57
  imapMailHostGuard,
50
58
  mapImapError,
51
59
  parseImapCursor,
52
- toRawInboundMessage,
53
60
  } from "./imap-client.ts";
54
- import { imapConnectScreen } from "./screens.ts";
61
+ import { buildProviderMessageId, coerceDate, parseOrReject } from "./parse-message.ts";
62
+ import { createReconnectMailboxHandler } from "./reconnect-mailbox.ts";
63
+ import { createReconnectPrefillQuery } from "./reconnect-prefill.query.ts";
64
+ import { createImapScreens } from "./screens.ts";
55
65
  import { sendFromImapAccount } from "./send-mail.ts";
56
66
 
57
67
  export { IMAP_PROVIDER_KEY } from "./constants.ts";
@@ -62,14 +72,31 @@ export { setImapMailHostLookup } from "./imap-client.ts";
62
72
  // fetch — inkrementeller UID-Sync
63
73
  // =============================================================================
64
74
 
75
+ async function unknownUidsOf(
76
+ uids: readonly number[],
77
+ uidValidity: string,
78
+ filterUnknownMessageIds: (ids: readonly string[]) => Promise<ReadonlySet<string>>,
79
+ ): Promise<number[]> {
80
+ const unknownIds = await filterUnknownMessageIds(
81
+ uids.map((uid) => buildProviderMessageId(uidValidity, uid)),
82
+ );
83
+ return uids.filter((uid) => unknownIds.has(buildProviderMessageId(uidValidity, uid)));
84
+ }
85
+
65
86
  async function fetchMessages(
66
87
  ctx: InboundMailContext,
67
88
  account: MailAccountRecord,
68
89
  cursor: Parameters<InboundMailProviderPlugin["fetch"]>[2],
69
- opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
90
+ opts: {
91
+ readonly backfillWindowDays: number;
92
+ readonly maxMessages: number;
93
+ readonly filterUnknownMessageIds?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
94
+ readonly signal?: AbortSignal;
95
+ },
70
96
  ): Promise<InboundFetchResult> {
71
97
  const doc = await readCredentialDocument(ctx, account);
72
98
  const client = await createImapClient(doc, imapMailHostGuard());
99
+ const stopWatchingAbort = closeOnAbort(client, opts.signal);
73
100
  let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
74
101
  try {
75
102
  await client.connect();
@@ -94,38 +121,31 @@ async function fetchMessages(
94
121
  }
95
122
  uids.sort((a, b) => a - b);
96
123
 
97
- const batch = uids.slice(0, opts.maxMessages);
98
- const hasMore = uids.length > batch.length;
99
- const messages: RawInboundMessage[] = [];
100
- let maxUid = parsedCursor?.lastUid ?? 0;
101
-
102
- if (batch.length > 0) {
103
- for await (const msg of client.fetch(
104
- batch.join(","),
105
- { uid: true, internalDate: true, source: true },
106
- { uid: true },
107
- )) {
108
- if (!msg.source) continue;
109
- messages.push(
110
- await toRawInboundMessage({
111
- source: msg.source,
112
- uid: msg.uid,
113
- uidValidity: serverUidValidity,
114
- internalDate: coerceDate(msg.internalDate ?? undefined),
115
- }),
116
- );
117
- if (msg.uid > maxUid) maxUid = msg.uid;
118
- }
119
- }
124
+ // Known mails (ingested or quarantined) are not downloaded again; the cursor still moves past them.
125
+ const unknown = opts.filterUnknownMessageIds
126
+ ? await unknownUidsOf(uids, serverUidValidity, opts.filterUnknownMessageIds)
127
+ : uids;
128
+ const batch = unknown.slice(0, opts.maxMessages);
129
+ const hasMore = unknown.length > batch.length;
130
+ const { messages, rejected } = await downloadBatch(
131
+ client,
132
+ batch,
133
+ serverUidValidity,
134
+ account.trustedAuthservId ?? null,
135
+ );
136
+ // From the UIDs, not from what was downloaded: a known or skipped mail must not hold the cursor back.
137
+ const lastUid = Math.max(parsedCursor?.lastUid ?? 0, ...(hasMore ? batch : uids));
120
138
 
121
139
  return {
122
140
  messages,
123
- nextCursor: { uidValidity: serverUidValidity, lastUid: maxUid },
141
+ ...(rejected.length > 0 && { rejected }),
142
+ nextCursor: { uidValidity: serverUidValidity, lastUid },
124
143
  hasMore,
125
144
  };
126
145
  } catch (err) {
127
146
  throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
128
147
  } finally {
148
+ stopWatchingAbort();
129
149
  lock?.release();
130
150
  await client.logout().catch(() => {});
131
151
  }
@@ -140,7 +160,10 @@ async function watchMailbox(
140
160
  ctx: InboundMailContext,
141
161
  account: MailAccountRecord,
142
162
  handlers: {
143
- readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
163
+ readonly onMessages: (
164
+ msgs: readonly RawInboundMessage[],
165
+ rejected?: readonly RejectedInboundMessage[],
166
+ ) => Promise<void>;
144
167
  readonly onError: (err: unknown) => void;
145
168
  },
146
169
  ): Promise<() => Promise<void>> {
@@ -158,6 +181,7 @@ async function watchMailbox(
158
181
 
159
182
  const drainNew = async () => {
160
183
  const messages: RawInboundMessage[] = [];
184
+ const rejected: RejectedInboundMessage[] = [];
161
185
  let maxSeen = nextUid - 1;
162
186
  for await (const msg of client.fetch(
163
187
  `${nextUid}:*`,
@@ -166,18 +190,19 @@ async function watchMailbox(
166
190
  )) {
167
191
  // `N:*` gotcha (see fetch): filter out the previous last message.
168
192
  if (msg.uid < nextUid || !msg.source) continue;
169
- messages.push(
170
- await toRawInboundMessage({
171
- source: msg.source,
172
- uid: msg.uid,
173
- uidValidity,
174
- internalDate: coerceDate(msg.internalDate ?? undefined),
175
- }),
176
- );
193
+ const parsed = await parseOrReject({
194
+ source: msg.source,
195
+ uid: msg.uid,
196
+ uidValidity,
197
+ internalDate: coerceDate(msg.internalDate ?? undefined),
198
+ trustedAuthservId: account.trustedAuthservId ?? null,
199
+ });
200
+ if ("message" in parsed) messages.push(parsed.message);
201
+ else rejected.push(parsed.rejected);
177
202
  if (msg.uid > maxSeen) maxSeen = msg.uid;
178
203
  }
179
204
  nextUid = maxSeen + 1;
180
- if (messages.length > 0) await handlers.onMessages(messages);
205
+ if (messages.length > 0 || rejected.length > 0) await handlers.onMessages(messages, rejected);
181
206
  };
182
207
 
183
208
  client.on("exists", () => {
@@ -236,6 +261,7 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
236
261
  verify: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), false),
237
262
  probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
238
263
  fetch: fetchMessages,
264
+ fetchContent: fetchMessageContent,
239
265
  watch: watchMailbox,
240
266
  send: sendFromImapAccount,
241
267
  // No oauth block: password authentication runs through the credential
@@ -243,12 +269,13 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
243
269
  // the OAuth providers (M365/Gmail).
244
270
  };
245
271
 
246
- export function createInboundProviderImapFeature() {
272
+ export function createInboundProviderImapFeature(options: InboundMailRoleOptions = {}) {
273
+ const roles = resolveInboundMailRoles(options);
247
274
  return defineFeature(
248
275
  FEATURE_NAME,
249
276
  (r) => {
250
277
  r.describe(
251
- 'Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a mailbox through the "Connect mailbox" form on the mail accounts list (`connect-mailbox` checks the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.',
278
+ 'Registers the `"imap"` provider for `inbound-mail-foundation` using imapflow — password/app-password auth covers classic IMAP hosts without any OAuth (plus an XOAUTH2 fallback consuming an access token from the secret slot). Incremental sync via a UIDVALIDITY:lastUid cursor (UIDVALIDITY change triggers a cursor-invalid full resync), initial backfill bounded by the foundation backfill window, and live push via IMAP IDLE for the watch supervisor. Tenant admins connect a shared mailbox through the "Connect mailbox" form on the mail accounts list and members a personal one through the same form on "My mail accounts" (`connect-mailbox` and `connect-my-mailbox` check the IMAP login and the SMTP login of the outgoing server first, then creates the account and its credential secret in one transaction; `reconnect-mailbox` does the same check for an existing mailbox with a sign-in error and overwrites its credential secret); programmatic setups store the per-account connection JSON (host/port/secure/user/password, plus smtpHost/smtpPort for sending) in the account credential secret slot before the first sync. Implements the provider `send`: `sendMailFromAccount` sends over the SMTP server of the mailbox itself (implicit TLS, same login) with the mailbox address as From and stores a copy in its sent folder.',
252
279
  );
253
280
  r.uiHints({
254
281
  displayLabel: "Inbound Mail · IMAP",
@@ -259,10 +286,14 @@ export function createInboundProviderImapFeature() {
259
286
  r.requires({ features: ["secrets"] });
260
287
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
261
288
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
262
- r.writeHandler(connectMailboxHandler);
263
- r.screen(imapConnectScreen);
289
+ r.bootCheck(({ features }) => assertRolesMatchFoundation(FEATURE_NAME, roles, features));
290
+ r.writeHandler(createConnectMailboxHandler(roles));
291
+ r.writeHandler(createConnectMyMailboxHandler(roles));
292
+ r.writeHandler(createReconnectMailboxHandler(roles));
293
+ r.queryHandler(createReconnectPrefillQuery(roles));
294
+ for (const screen of createImapScreens(roles)) r.screen(screen);
264
295
  r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
265
296
  },
266
- { dedupeOptions: {} },
297
+ { dedupeOptions: roleDedupeOptions(roles) },
267
298
  );
268
299
  }
@@ -0,0 +1,84 @@
1
+ // Re-downloads already imported mails whose content was stored as snippet only. A provider message
2
+ // id is "<uidValidity>:<uid>"; ids from another UIDVALIDITY or gone from the server are "missing"
3
+ // (nothing to reload, ever), mails the parser cannot read are "rejected".
4
+
5
+ import type {
6
+ InboundContentFetchResult,
7
+ InboundMailContext,
8
+ MailAccountRecord,
9
+ RawInboundMessage,
10
+ RejectedInboundMessage,
11
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
12
+ import { InboundTransientError } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
13
+ import { readCredentialDocument } from "./credential-document.ts";
14
+ import {
15
+ closeOnAbort,
16
+ createImapClient,
17
+ IMAP_MAILBOX,
18
+ imapMailHostGuard,
19
+ mapImapError,
20
+ } from "./imap-client.ts";
21
+ import { coerceDate, parseOrReject } from "./parse-message.ts";
22
+
23
+ function uidOf(providerMessageId: string, serverUidValidity: string): number | null {
24
+ const separator = providerMessageId.lastIndexOf(":");
25
+ if (separator < 0 || providerMessageId.slice(0, separator) !== serverUidValidity) return null;
26
+ const uid = Number(providerMessageId.slice(separator + 1));
27
+ return Number.isInteger(uid) && uid > 0 ? uid : null;
28
+ }
29
+
30
+ export async function fetchMessageContent(
31
+ ctx: InboundMailContext,
32
+ account: MailAccountRecord,
33
+ providerMessageIds: readonly string[],
34
+ opts?: { readonly signal?: AbortSignal },
35
+ ): Promise<InboundContentFetchResult> {
36
+ const doc = await readCredentialDocument(ctx, account);
37
+ const client = await createImapClient(doc, imapMailHostGuard());
38
+ const stopWatchingAbort = closeOnAbort(client, opts?.signal);
39
+ let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
40
+ try {
41
+ await client.connect();
42
+ lock = await client.getMailboxLock(IMAP_MAILBOX);
43
+ const mailbox = client.mailbox;
44
+ if (typeof mailbox === "boolean") throw new InboundTransientError("mailbox not open");
45
+ const serverUidValidity = String(mailbox.uidValidity);
46
+ const idByUid = new Map<number, string>();
47
+ const missing: string[] = [];
48
+ for (const id of providerMessageIds) {
49
+ const uid = uidOf(id, serverUidValidity);
50
+ if (uid === null) missing.push(id);
51
+ else idByUid.set(uid, id);
52
+ }
53
+ const messages: RawInboundMessage[] = [];
54
+ const rejected: RejectedInboundMessage[] = [];
55
+ if (idByUid.size > 0) {
56
+ for await (const msg of client.fetch(
57
+ [...idByUid.keys()].join(","),
58
+ { uid: true, internalDate: true, source: true, envelope: true },
59
+ { uid: true },
60
+ )) {
61
+ if (!msg.source) continue;
62
+ const parsed = await parseOrReject({
63
+ source: msg.source,
64
+ uid: msg.uid,
65
+ uidValidity: serverUidValidity,
66
+ internalDate: coerceDate(msg.internalDate ?? undefined),
67
+ envelope: msg.envelope,
68
+ });
69
+ idByUid.delete(msg.uid);
70
+ if ("message" in parsed) messages.push(parsed.message);
71
+ else rejected.push(parsed.rejected);
72
+ }
73
+ }
74
+ // What the server did not return is gone (expunged since the import).
75
+ missing.push(...idByUid.values());
76
+ return { messages, missing, rejected };
77
+ } catch (err) {
78
+ throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
79
+ } finally {
80
+ stopWatchingAbort();
81
+ lock?.release();
82
+ await client.logout().catch(() => {});
83
+ }
84
+ }
@@ -1,13 +1,27 @@
1
- import { IMAP_CONNECT_SCREEN_ID } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
1
+ import {
2
+ IMAP_CONNECT_SCREEN_ID,
3
+ IMAP_RECONNECT_SCREEN_ID,
4
+ MY_IMAP_CONNECT_SCREEN_ID,
5
+ MY_IMAP_RECONNECT_SCREEN_ID,
6
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
2
7
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
3
8
  import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
4
- import { FEATURE_NAME, IMAP_CONNECT_ERRORS, SMTP_CONNECT_ERRORS } from "./constants.ts";
9
+ import {
10
+ FEATURE_NAME,
11
+ IMAP_CONNECT_ERRORS,
12
+ IMAP_RECONNECT_ERRORS,
13
+ SMTP_CONNECT_ERRORS,
14
+ } from "./constants.ts";
5
15
 
6
16
  const formFieldKey = (field: string): string =>
7
17
  fieldLabelKey(FEATURE_NAME, ACTION_FORM_ENTITY, field);
8
18
 
9
19
  export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en: string }>> = {
10
20
  [`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
21
+ [`screen:${IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
22
+ [`screen:${MY_IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
23
+ [`screen:${MY_IMAP_RECONNECT_SCREEN_ID}.title`]: { en: "Reconnect mailbox" },
24
+ [formFieldKey("accountId")]: { en: "Mailbox" },
11
25
  [formFieldKey("displayName")]: { en: "Name" },
12
26
  [formFieldKey("address")]: { en: "Email address" },
13
27
  [formFieldKey("host")]: { en: "IMAP server" },
@@ -21,7 +35,19 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
21
35
  "inboundMail.imap.connect.outgoingHint": {
22
36
  en: "Replies go out from this address over your own mail server, with the same user name and password. Leave the field empty if the server is called like the IMAP server, with smtp. instead of imap. Usually 587, even when your provider lists 465/SSL.",
23
37
  },
38
+ [formFieldKey("trustedAuthservId")]: { en: "Mail server ID" },
39
+ "inboundMail.imap.connect.senderCheck": { en: "Sender check" },
40
+ "inboundMail.imap.connect.senderCheckHint": {
41
+ en: "Identifier of the mail server from the Authentication-Results header, for example mx.example.com. Only with it the system checks whether a sender is genuine. Leave empty if unknown.",
42
+ },
24
43
  "inboundMail.imap.connect.submit": { en: "Check and connect" },
44
+ "inboundMail.imap.reconnect.hint": {
45
+ en: "The sign-in is checked before saving. Then the system fetches the mail again.",
46
+ },
47
+ "inboundMail.imap.reconnect.submit": { en: "Check and reconnect" },
48
+ [IMAP_RECONNECT_ERRORS.notPasswordImap.i18nKey]: {
49
+ en: "Only password IMAP mailboxes can be reconnected here.",
50
+ },
25
51
  [IMAP_CONNECT_ERRORS.hostUnreachable.i18nKey]: {
26
52
  en: "The IMAP server cannot be reached or is not allowed. Check the server name.",
27
53
  },
@@ -2,6 +2,7 @@
2
2
  // Reference implementation: /Users/marc/code/doc-o-mat (HEINZ), but uses an
3
3
  // incremental UIDVALIDITY:lastUid cursor instead of a '1:*' full scan.
4
4
 
5
+ import { isIP } from "node:net";
5
6
  import {
6
7
  BlockedHostError,
7
8
  type MailHostGuardOptions,
@@ -12,22 +13,24 @@ import {
12
13
  InboundAuthError,
13
14
  InboundCursorInvalidError,
14
15
  InboundTransientError,
15
- type RawInboundMessage,
16
16
  type SyncCursorPayload,
17
17
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
18
18
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
19
- import { legacyDateToInstant } from "@cosmicdrift/kumiko-framework/time";
20
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
21
19
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
22
20
  import { ImapFlow } from "imapflow";
23
- import { type AddressObject, type ParsedMail, simpleParser } from "mailparser";
24
21
  import type { ImapCredentialDocument } from "./credential-document.ts";
25
22
 
26
23
  export const IMAP_MAILBOX = "INBOX";
27
- const SNIPPET_MAX = 300;
28
24
 
29
25
  const log = createFallbackLogger("inbound-provider-imap");
30
26
 
27
+ // Explicit instead of imapflow's defaults (90 s connect, 16 s greeting, 5 min idle socket): a
28
+ // mailbox that stops answering must fail within a poll, not minutes later. The socket timeout
29
+ // stays at the library's value because IDLE connections rely on it between server keep-alives.
30
+ const IMAP_CONNECTION_TIMEOUT_MS = 20_000;
31
+ const IMAP_GREETING_TIMEOUT_MS = 16_000;
32
+ const IMAP_SOCKET_TIMEOUT_MS = 5 * 60 * 1000;
33
+
31
34
  // Tenant-visible for both branches below — must not reveal whether the host
32
35
  // was blocked (private/reserved range) or merely failed to resolve, and
33
36
  // must never include the host or the underlying error text. Server-side
@@ -80,17 +83,41 @@ export async function createImapClient(
80
83
  log.warn("host resolution failed", { host: doc.host, reason });
81
84
  throw new InboundTransientError(IMAP_HOST_UNREACHABLE_MESSAGE);
82
85
  }
83
- return new ImapFlow({
86
+ return new ImapFlow(imapClientOptions(doc, target));
87
+ }
88
+
89
+ // For an IP-literal host imapflow itself sets `servername: false`, which Bun's tls.connect rejects
90
+ // ("servername argument must be an string"). Overriding it through `tls` with undefined makes the
91
+ // socket connect without SNI; the certificate is still verified against the IP. Not setting the
92
+ // option at all (as for SMTP, where nodemailer omits a falsy servername) does not help here.
93
+ export function imapClientOptions(
94
+ doc: ImapCredentialDocument,
95
+ target: Awaited<ReturnType<typeof resolveMailConnectTarget>>,
96
+ ) {
97
+ return {
84
98
  host: target.host,
85
99
  port: doc.port,
86
100
  secure: doc.secure,
87
101
  ...(target.servername && { servername: target.servername }),
102
+ ...(isIP(target.host) !== 0 && { tls: { servername: undefined } }),
88
103
  auth: doc.password
89
104
  ? { user: doc.user, pass: doc.password }
90
105
  : { user: doc.user, accessToken: doc.accessToken as string },
91
- logger: false,
106
+ connectionTimeout: IMAP_CONNECTION_TIMEOUT_MS,
107
+ greetingTimeout: IMAP_GREETING_TIMEOUT_MS,
108
+ socketTimeout: IMAP_SOCKET_TIMEOUT_MS,
109
+ logger: false as const,
92
110
  emitLogs: false,
93
- });
111
+ };
112
+ }
113
+
114
+ /** Drops the connection when the supervisor abandons the sync, so a hung command does not keep the socket open. */
115
+ export function closeOnAbort(client: ImapFlow, signal: AbortSignal | undefined): () => void {
116
+ if (!signal) return () => {};
117
+ const close = () => client.close();
118
+ if (signal.aborted) close();
119
+ signal.addEventListener("abort", close, { once: true });
120
+ return () => signal.removeEventListener("abort", close);
94
121
  }
95
122
 
96
123
  /** Server-Fehler → typisierte Foundation-Fehlerklassen (Plan §2). */
@@ -155,79 +182,3 @@ export function assertUidValidity(cursor: ImapCursor | null, serverUidValidity:
155
182
  );
156
183
  }
157
184
  }
158
-
159
- // =============================================================================
160
- // Message-Normalisierung → RawInboundMessage
161
- // =============================================================================
162
-
163
- function stripAngles(id: string): string {
164
- return id.replace(/^<|>$/g, "").trim();
165
- }
166
-
167
- /** Normalizes the References chain: mailparser returns string | string[]
168
- * | undefined; when References is missing, In-Reply-To provides the thread
169
- * anchor. */
170
- export function normalizeReferences(parsed: {
171
- references?: string | string[];
172
- inReplyTo?: string;
173
- }): string[] {
174
- const raw = parsed.references;
175
- const list = raw === undefined ? [] : Array.isArray(raw) ? raw : raw.split(/\s+/);
176
- const refs = list.map(stripAngles).filter((r) => r.length > 0);
177
- if (refs.length === 0 && parsed.inReplyTo) {
178
- const inReplyTo = stripAngles(parsed.inReplyTo);
179
- if (inReplyTo) return [inReplyTo];
180
- }
181
- return refs;
182
- }
183
-
184
- function addressList(a: AddressObject | AddressObject[] | undefined): string[] {
185
- if (!a) return [];
186
- const arr = Array.isArray(a) ? a : [a];
187
- return arr.map((x) => x.text).filter((t): t is string => Boolean(t));
188
- }
189
-
190
- /** imapflow provides internalDate as either a Date or string depending on
191
- * the code path. The string form is deliberately discarded (no date parsing
192
- * in feature code, no-date-api); toRawInboundMessage then falls back to the
193
- * mailparser Date header, which is always a real Date. */
194
- export function coerceDate(d: Date | string | undefined): Date | undefined {
195
- return d instanceof Date ? d : undefined;
196
- }
197
-
198
- /** providerMessageId: a UID is unique only within a UIDVALIDITY; together,
199
- * they form the stable idempotency anchor. */
200
- export function buildProviderMessageId(uidValidity: string, uid: number): string {
201
- return `${uidValidity}:${uid}`;
202
- }
203
-
204
- export async function toRawInboundMessage(args: {
205
- readonly source: Uint8Array;
206
- readonly uid: number;
207
- readonly uidValidity: string;
208
- readonly internalDate: Date | undefined;
209
- }): Promise<RawInboundMessage> {
210
- const parsed: ParsedMail = await simpleParser(Buffer.from(args.source));
211
- const messageIdHeader = parsed.messageId ? stripAngles(parsed.messageId) : null;
212
- const receivedAt = args.internalDate ?? parsed.date;
213
- const text = parsed.text?.trim() ?? "";
214
- return {
215
- providerMessageId: buildProviderMessageId(args.uidValidity, args.uid),
216
- messageIdHeader: messageIdHeader && messageIdHeader.length > 0 ? messageIdHeader : null,
217
- providerThreadId: null,
218
- references: normalizeReferences(parsed),
219
- from: parsed.from?.text ?? "",
220
- to: addressList(parsed.to),
221
- cc: addressList(parsed.cc),
222
- subject: parsed.subject ?? "",
223
- snippet: text.slice(0, SNIPPET_MAX),
224
- // Lib-boundary bridge (Date from imapflow/mailparser) → Temporal;
225
- // when no date exists, Epoch is the honest marker.
226
- receivedAtIso: (receivedAt
227
- ? legacyDateToInstant(receivedAt)
228
- : Temporal.Instant.fromEpochMilliseconds(0)
229
- ).toString(),
230
- rawMime: args.source,
231
- scope: "inbox",
232
- };
233
- }
@@ -13,9 +13,8 @@ export {
13
13
  } from "./feature.ts";
14
14
  export {
15
15
  assertUidValidity,
16
- buildProviderMessageId,
17
16
  type ImapCursor,
18
17
  mapImapError,
19
- normalizeReferences,
20
18
  parseImapCursor,
21
19
  } from "./imap-client.ts";
20
+ export { buildProviderMessageId, normalizeReferences } from "./parse-message.ts";
@@ -0,0 +1,85 @@
1
+ // Turns a parsed MIME tree into the content the foundation stores: full text, HTML, real
2
+ // attachments, and the attachments it deliberately leaves out. Pure; the parsing itself stays in
3
+ // imap-client.ts.
4
+
5
+ import type {
6
+ RawInboundAttachment,
7
+ SkippedInboundAttachment,
8
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
9
+ import { type Attachment, type ParsedMail, simpleParser } from "mailparser";
10
+
11
+ /** A forwarded mail inside a forwarded mail inside a forwarded mail is still read; deeper is skipped. */
12
+ export const MIME_FORWARD_MAX_DEPTH = 3;
13
+
14
+ const FORWARDED_MESSAGE_MIME = "message/rfc822";
15
+ const FALLBACK_ATTACHMENT_NAME = "attachment";
16
+ const FALLBACK_FORWARDED_NAME = "forwarded.eml";
17
+
18
+ export type MailContent = {
19
+ readonly text: string;
20
+ readonly html: string | null;
21
+ readonly attachments: readonly RawInboundAttachment[];
22
+ readonly skipped: readonly SkippedInboundAttachment[];
23
+ };
24
+
25
+ // Signature logos and tracking pixels travel as inline images; a PDF with a Content-Id (Apple Mail
26
+ // inlines PDFs that way) is a real document and must survive.
27
+ function isInlineImage(attachment: Attachment): boolean {
28
+ if (!attachment.contentType.toLowerCase().startsWith("image/")) return false;
29
+ const referencedByBody = attachment.related === true;
30
+ const inlineWithContentId =
31
+ attachment.contentDisposition === "inline" && Boolean(attachment.contentId);
32
+ return referencedByBody || inlineWithContentId;
33
+ }
34
+
35
+ function forwardedHeader(inner: ParsedMail): string {
36
+ return `\n\n---------- Forwarded message ----------\nFrom: ${inner.from?.text ?? ""}\nSubject: ${inner.subject ?? ""}\n\n`;
37
+ }
38
+
39
+ async function readForwardedMail(
40
+ source: Buffer,
41
+ depth: number,
42
+ ): Promise<{ readonly header: string; readonly content: MailContent } | null> {
43
+ try {
44
+ const parsed = await simpleParser(source);
45
+ return { header: forwardedHeader(parsed), content: await extractMailContent(parsed, depth) };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function extractMailContent(parsed: ParsedMail, depth = 0): Promise<MailContent> {
52
+ let text = parsed.text?.trim() ?? "";
53
+ const attachments: RawInboundAttachment[] = [];
54
+ const skipped: SkippedInboundAttachment[] = [];
55
+ for (const attachment of parsed.attachments) {
56
+ const mimeType = attachment.contentType;
57
+ if (isInlineImage(attachment)) continue;
58
+ if (mimeType.toLowerCase() === FORWARDED_MESSAGE_MIME) {
59
+ if (depth + 1 > MIME_FORWARD_MAX_DEPTH) {
60
+ skipped.push({ mimeType, size: attachment.size, reason: "nesting_too_deep" });
61
+ continue;
62
+ }
63
+ const inner = await readForwardedMail(attachment.content, depth + 1);
64
+ if (inner === null) {
65
+ // A forwarded mail the parser chokes on must not sink the outer mail: keep it as a plain .eml.
66
+ attachments.push({
67
+ fileName: attachment.filename ?? FALLBACK_FORWARDED_NAME,
68
+ mimeType,
69
+ data: attachment.content,
70
+ });
71
+ continue;
72
+ }
73
+ text += inner.header + inner.content.text;
74
+ attachments.push(...inner.content.attachments);
75
+ skipped.push(...inner.content.skipped);
76
+ continue;
77
+ }
78
+ attachments.push({
79
+ fileName: attachment.filename ?? FALLBACK_ATTACHMENT_NAME,
80
+ mimeType,
81
+ data: attachment.content,
82
+ });
83
+ }
84
+ return { text, html: parsed.html || null, attachments, skipped };
85
+ }