@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 (226) 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 +143 -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 -2
  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 +93 -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 +238 -89
  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 +36 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
  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 +2 -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 +61 -136
  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 +9 -0
  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 +4 -6
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
  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-imap/watch-mailbox.ts +109 -0
  133. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  134. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  135. package/packages/bundled-features/src/jobs/changes.json +18 -0
  136. package/packages/bundled-features/src/managed-pages/changes.json +7 -0
  137. package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
  138. package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
  139. package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
  140. package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
  141. package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
  142. package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
  143. package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
  144. package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
  145. package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
  146. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
  147. package/packages/bundled-features/src/managed-pages/table.ts +6 -2
  148. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
  149. package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
  150. package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
  151. package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
  152. package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
  153. package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -0
  154. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  155. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  156. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  157. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  158. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  159. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  160. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  161. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  162. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  163. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  164. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  165. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  166. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  167. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  168. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  169. package/packages/bundled-features/src/template-resolver/README.md +2 -2
  170. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  171. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
  172. package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
  173. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  174. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +24 -6
  176. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
  177. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
  178. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
  179. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
  180. package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
  181. package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
  182. package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
  183. package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
  184. package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
  186. package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
  188. package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
  189. package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
  190. package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
  191. package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
  193. package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
  194. package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
  195. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  196. package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
  197. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  198. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  199. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  200. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  201. package/samples/apps/use-all-bundled/feature-manifest.json +89 -40
  202. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  203. package/samples/apps/use-all-bundled/package.json +1 -0
  204. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  205. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  206. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  207. package/samples/apps/use-all-bundled/src/app/client.tsx +8 -1
  208. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  209. package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
  210. package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
  211. package/samples/recipes/cross-feature-events/README.md +10 -0
  212. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  213. package/samples/recipes/dashboard-stats/README.md +8 -0
  214. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  215. package/samples/recipes/env-composition/README.md +2 -2
  216. package/samples/recipes/env-composition/src/env.ts +2 -2
  217. package/samples/recipes/inbound-mail-triage/README.md +8 -0
  218. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  219. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  220. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  221. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  222. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  223. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  224. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  225. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  226. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -25,36 +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
- type RawInboundMessage,
35
+ resolveInboundMailRoles,
36
+ roleDedupeOptions,
34
37
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
35
38
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
36
39
  import { instantToLegacyDate } from "@cosmicdrift/kumiko-framework/time";
37
40
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
38
- import type { ImapFlow, MailboxObject } from "imapflow";
39
- import { connectMailboxHandler } from "./connect-mailbox.ts";
41
+ import type { MailboxObject } from "imapflow";
42
+ import { createConnectMailboxHandler } from "./connect-mailbox.ts";
43
+ import { createConnectMyMailboxHandler } from "./connect-my-mailbox.ts";
40
44
  import { FEATURE_NAME, IMAP_PROVIDER_KEY } from "./constants.ts";
41
45
  import { readCredentialDocument } from "./credential-document.ts";
46
+ import { downloadBatch } from "./download-batch.ts";
47
+ import { fetchMessageContent } from "./fetch-content.ts";
42
48
  import { INBOUND_PROVIDER_IMAP_I18N } from "./i18n.ts";
43
49
  import {
44
50
  assertUidValidity,
45
- coerceDate,
51
+ closeOnAbort,
46
52
  connectOnce,
47
53
  createImapClient,
48
54
  IMAP_MAILBOX,
49
55
  imapMailHostGuard,
50
56
  mapImapError,
51
57
  parseImapCursor,
52
- toRawInboundMessage,
53
58
  } from "./imap-client.ts";
54
- import { reconnectMailboxHandler } from "./reconnect-mailbox.ts";
55
- import { reconnectPrefillQuery } from "./reconnect-prefill.query.ts";
56
- import { imapConnectScreen, imapReconnectScreen } from "./screens.ts";
59
+ import { buildProviderMessageId } from "./parse-message.ts";
60
+ import { createReconnectMailboxHandler } from "./reconnect-mailbox.ts";
61
+ import { createReconnectPrefillQuery } from "./reconnect-prefill.query.ts";
62
+ import { createImapScreens } from "./screens.ts";
57
63
  import { sendFromImapAccount } from "./send-mail.ts";
64
+ import { watchMailbox } from "./watch-mailbox.ts";
58
65
 
59
66
  export { IMAP_PROVIDER_KEY } from "./constants.ts";
60
67
 
@@ -64,14 +71,31 @@ export { setImapMailHostLookup } from "./imap-client.ts";
64
71
  // fetch — inkrementeller UID-Sync
65
72
  // =============================================================================
66
73
 
74
+ async function unknownUidsOf(
75
+ uids: readonly number[],
76
+ uidValidity: string,
77
+ filterUnknownMessageIds: (ids: readonly string[]) => Promise<ReadonlySet<string>>,
78
+ ): Promise<number[]> {
79
+ const unknownIds = await filterUnknownMessageIds(
80
+ uids.map((uid) => buildProviderMessageId(uidValidity, uid)),
81
+ );
82
+ return uids.filter((uid) => unknownIds.has(buildProviderMessageId(uidValidity, uid)));
83
+ }
84
+
67
85
  async function fetchMessages(
68
86
  ctx: InboundMailContext,
69
87
  account: MailAccountRecord,
70
88
  cursor: Parameters<InboundMailProviderPlugin["fetch"]>[2],
71
- opts: { readonly backfillWindowDays: number; readonly maxMessages: number },
89
+ opts: {
90
+ readonly backfillWindowDays: number;
91
+ readonly maxMessages: number;
92
+ readonly filterUnknownMessageIds?: (ids: readonly string[]) => Promise<ReadonlySet<string>>;
93
+ readonly signal?: AbortSignal;
94
+ },
72
95
  ): Promise<InboundFetchResult> {
73
96
  const doc = await readCredentialDocument(ctx, account);
74
97
  const client = await createImapClient(doc, imapMailHostGuard());
98
+ const stopWatchingAbort = closeOnAbort(client, opts.signal);
75
99
  let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | undefined;
76
100
  try {
77
101
  await client.connect();
@@ -96,138 +120,36 @@ async function fetchMessages(
96
120
  }
97
121
  uids.sort((a, b) => a - b);
98
122
 
99
- const batch = uids.slice(0, opts.maxMessages);
100
- const hasMore = uids.length > batch.length;
101
- const messages: RawInboundMessage[] = [];
102
- let maxUid = parsedCursor?.lastUid ?? 0;
103
-
104
- if (batch.length > 0) {
105
- for await (const msg of client.fetch(
106
- batch.join(","),
107
- { uid: true, internalDate: true, source: true },
108
- { uid: true },
109
- )) {
110
- if (!msg.source) continue;
111
- messages.push(
112
- await toRawInboundMessage({
113
- source: msg.source,
114
- uid: msg.uid,
115
- uidValidity: serverUidValidity,
116
- internalDate: coerceDate(msg.internalDate ?? undefined),
117
- }),
118
- );
119
- if (msg.uid > maxUid) maxUid = msg.uid;
120
- }
121
- }
123
+ // Known mails (ingested or quarantined) are not downloaded again; the cursor still moves past them.
124
+ const unknown = opts.filterUnknownMessageIds
125
+ ? await unknownUidsOf(uids, serverUidValidity, opts.filterUnknownMessageIds)
126
+ : uids;
127
+ const batch = unknown.slice(0, opts.maxMessages);
128
+ const hasMore = unknown.length > batch.length;
129
+ const { messages, rejected } = await downloadBatch(
130
+ client,
131
+ batch,
132
+ serverUidValidity,
133
+ account.trustedAuthservId ?? null,
134
+ );
135
+ // From the UIDs, not from what was downloaded: a known or skipped mail must not hold the cursor back.
136
+ const lastUid = Math.max(parsedCursor?.lastUid ?? 0, ...(hasMore ? batch : uids));
122
137
 
123
138
  return {
124
139
  messages,
125
- nextCursor: { uidValidity: serverUidValidity, lastUid: maxUid },
140
+ ...(rejected.length > 0 && { rejected }),
141
+ nextCursor: { uidValidity: serverUidValidity, lastUid },
126
142
  hasMore,
127
143
  };
128
144
  } catch (err) {
129
145
  throw err instanceof Error && "kind" in err ? err : mapImapError(err, doc.host);
130
146
  } finally {
147
+ stopWatchingAbort();
131
148
  lock?.release();
132
149
  await client.logout().catch(() => {});
133
150
  }
134
151
  }
135
152
 
136
- // =============================================================================
137
- // watch — IMAP IDLE (imapflow idles automatically while the mailbox is open
138
- // and emits 'exists' for new messages).
139
- // =============================================================================
140
-
141
- async function watchMailbox(
142
- ctx: InboundMailContext,
143
- account: MailAccountRecord,
144
- handlers: {
145
- readonly onMessages: (msgs: readonly RawInboundMessage[]) => Promise<void>;
146
- readonly onError: (err: unknown) => void;
147
- },
148
- ): Promise<() => Promise<void>> {
149
- const doc = await readCredentialDocument(ctx, account);
150
- const client: ImapFlow = await createImapClient(doc, imapMailHostGuard());
151
- let stopped = false;
152
-
153
- try {
154
- await client.connect();
155
- const mailbox = (await client.mailboxOpen(IMAP_MAILBOX)) as MailboxObject;
156
- const uidValidity = String(mailbox.uidValidity);
157
- // uidNext is the next UID to be assigned, so everything from here is
158
- // "new".
159
- let nextUid = Number(mailbox.uidNext ?? 1);
160
-
161
- const drainNew = async () => {
162
- const messages: RawInboundMessage[] = [];
163
- let maxSeen = nextUid - 1;
164
- for await (const msg of client.fetch(
165
- `${nextUid}:*`,
166
- { uid: true, internalDate: true, source: true },
167
- { uid: true },
168
- )) {
169
- // `N:*` gotcha (see fetch): filter out the previous last message.
170
- if (msg.uid < nextUid || !msg.source) continue;
171
- messages.push(
172
- await toRawInboundMessage({
173
- source: msg.source,
174
- uid: msg.uid,
175
- uidValidity,
176
- internalDate: coerceDate(msg.internalDate ?? undefined),
177
- }),
178
- );
179
- if (msg.uid > maxSeen) maxSeen = msg.uid;
180
- }
181
- nextUid = maxSeen + 1;
182
- if (messages.length > 0) await handlers.onMessages(messages);
183
- };
184
-
185
- client.on("exists", () => {
186
- // skip: ignore delayed exists events after the watcher has stopped.
187
- if (stopped) return;
188
- void drainNew().catch((err) => {
189
- if (!stopped) handlers.onError(mapImapError(err, doc.host));
190
- });
191
- });
192
- const die = (err: unknown) => {
193
- // skip: already stopped or terminated; fire onError only once.
194
- if (stopped) return;
195
- stopped = true;
196
- handlers.onError(mapImapError(err, doc.host));
197
- };
198
- client.on("error", die);
199
- client.on("close", () => die(new Error("connection closed")));
200
-
201
- // IDLE loop: 'exists' fires ONLY while client.idle() is running
202
- // (verified empirically against greenmail). Each intervening command
203
- // (drainNew-fetch) ends idle(), so the loop re-enters idle() until
204
- // stop() closes the connection.
205
- void (async () => {
206
- while (!stopped) {
207
- try {
208
- await client.idle();
209
- } catch (err) {
210
- if (!stopped) die(err);
211
- // skip: the idle loop ends; die() has informed the supervisor (or stop()
212
- // was the cause).
213
- return;
214
- }
215
- }
216
- })();
217
-
218
- return async () => {
219
- stopped = true;
220
- client.removeAllListeners("exists");
221
- client.removeAllListeners("error");
222
- client.removeAllListeners("close");
223
- await client.logout().catch(() => {});
224
- };
225
- } catch (err) {
226
- await client.logout().catch(() => {});
227
- throw mapImapError(err, doc.host);
228
- }
229
- }
230
-
231
153
  // =============================================================================
232
154
  // Plugin + Feature-definition
233
155
  // =============================================================================
@@ -238,6 +160,7 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
238
160
  verify: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), false),
239
161
  probe: async (ctx, account) => connectOnce(await readCredentialDocument(ctx, account), true),
240
162
  fetch: fetchMessages,
163
+ fetchContent: fetchMessageContent,
241
164
  watch: watchMailbox,
242
165
  send: sendFromImapAccount,
243
166
  // No oauth block: password authentication runs through the credential
@@ -245,12 +168,13 @@ export const imapInboundMailPlugin: InboundMailProviderPlugin = {
245
168
  // the OAuth providers (M365/Gmail).
246
169
  };
247
170
 
248
- export function createInboundProviderImapFeature() {
171
+ export function createInboundProviderImapFeature(options: InboundMailRoleOptions = {}) {
172
+ const roles = resolveInboundMailRoles(options);
249
173
  return defineFeature(
250
174
  FEATURE_NAME,
251
175
  (r) => {
252
176
  r.describe(
253
- '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; `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.',
177
+ '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.',
254
178
  );
255
179
  r.uiHints({
256
180
  displayLabel: "Inbound Mail · IMAP",
@@ -261,13 +185,14 @@ export function createInboundProviderImapFeature() {
261
185
  r.requires({ features: ["secrets"] });
262
186
  r.envSchema(mailAllowedPrivateHostsEnvSchema);
263
187
  r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, IMAP_PROVIDER_KEY, imapInboundMailPlugin);
264
- r.writeHandler(connectMailboxHandler);
265
- r.writeHandler(reconnectMailboxHandler);
266
- r.queryHandler(reconnectPrefillQuery);
267
- r.screen(imapConnectScreen);
268
- r.screen(imapReconnectScreen);
188
+ r.bootCheck(({ features }) => assertRolesMatchFoundation(FEATURE_NAME, roles, features));
189
+ r.writeHandler(createConnectMailboxHandler(roles));
190
+ r.writeHandler(createConnectMyMailboxHandler(roles));
191
+ r.writeHandler(createReconnectMailboxHandler(roles));
192
+ r.queryHandler(createReconnectPrefillQuery(roles));
193
+ for (const screen of createImapScreens(roles)) r.screen(screen);
269
194
  r.translations({ keys: INBOUND_PROVIDER_IMAP_I18N });
270
195
  },
271
- { dedupeOptions: {} },
196
+ { dedupeOptions: roleDedupeOptions(roles) },
272
197
  );
273
198
  }
@@ -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,6 +1,8 @@
1
1
  import {
2
2
  IMAP_CONNECT_SCREEN_ID,
3
3
  IMAP_RECONNECT_SCREEN_ID,
4
+ MY_IMAP_CONNECT_SCREEN_ID,
5
+ MY_IMAP_RECONNECT_SCREEN_ID,
4
6
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
7
  import { ACTION_FORM_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
6
8
  import { fieldLabelKey } from "@cosmicdrift/kumiko-renderer/headless";
@@ -17,6 +19,8 @@ const formFieldKey = (field: string): string =>
17
19
  export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en: string }>> = {
18
20
  [`screen:${IMAP_CONNECT_SCREEN_ID}.title`]: { en: "Connect mailbox" },
19
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" },
20
24
  [formFieldKey("accountId")]: { en: "Mailbox" },
21
25
  [formFieldKey("displayName")]: { en: "Name" },
22
26
  [formFieldKey("address")]: { en: "Email address" },
@@ -31,6 +35,11 @@ export const INBOUND_PROVIDER_IMAP_I18N: Readonly<Record<string, { readonly en:
31
35
  "inboundMail.imap.connect.outgoingHint": {
32
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.",
33
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
+ },
34
43
  "inboundMail.imap.connect.submit": { en: "Check and connect" },
35
44
  "inboundMail.imap.reconnect.hint": {
36
45
  en: "The sign-in is checked before saving. Then the system fetches the mail again.",
@@ -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
+ }