@cosmicdrift/kumiko-samples 0.392.2 → 0.394.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  133. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  134. package/packages/bundled-features/src/jobs/changes.json +18 -0
  135. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
  136. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  137. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  138. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  139. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  140. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  141. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  142. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  143. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  144. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  145. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  146. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  147. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  148. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  149. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  150. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  151. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  153. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
  155. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
  156. package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
  157. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  158. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  161. package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
  162. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  163. package/samples/apps/use-all-bundled/package.json +1 -0
  164. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  165. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  166. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  167. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  168. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  169. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
  170. package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
  171. package/samples/recipes/cross-feature-events/README.md +10 -0
  172. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  173. package/samples/recipes/dashboard-stats/README.md +8 -0
  174. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  175. package/samples/recipes/env-composition/README.md +2 -2
  176. package/samples/recipes/env-composition/src/env.ts +2 -2
  177. package/samples/recipes/inbound-mail-triage/README.md +12 -0
  178. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  179. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  180. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  181. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  182. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  183. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  184. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  185. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  186. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,149 @@
1
+ import type {
2
+ RawInboundMessage,
3
+ RejectedInboundMessage,
4
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
+ import { legacyDateToInstant } from "@cosmicdrift/kumiko-framework/time";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
+ import type { MessageEnvelopeObject } from "imapflow";
9
+ import { type AddressObject, type ParsedMail, simpleParser } from "mailparser";
10
+ import { extractMailContent } from "./mime-content.ts";
11
+ import { senderAuthenticationOf } from "./sender-auth-headers.ts";
12
+
13
+ const SNIPPET_MAX = 300;
14
+
15
+ const log = createFallbackLogger("inbound-provider-imap");
16
+
17
+ // =============================================================================
18
+ // Message-Normalisierung → RawInboundMessage
19
+ // =============================================================================
20
+
21
+ function stripAngles(id: string): string {
22
+ return id.replace(/^<|>$/g, "").trim();
23
+ }
24
+
25
+ /** Normalizes the References chain: mailparser returns string | string[]
26
+ * | undefined; when References is missing, In-Reply-To provides the thread
27
+ * anchor. */
28
+ export function normalizeReferences(parsed: {
29
+ references?: string | string[];
30
+ inReplyTo?: string;
31
+ }): string[] {
32
+ const raw = parsed.references;
33
+ const list = raw === undefined ? [] : Array.isArray(raw) ? raw : raw.split(/\s+/);
34
+ const refs = list.map(stripAngles).filter((r) => r.length > 0);
35
+ if (refs.length === 0 && parsed.inReplyTo) {
36
+ const inReplyTo = stripAngles(parsed.inReplyTo);
37
+ if (inReplyTo) return [inReplyTo];
38
+ }
39
+ return refs;
40
+ }
41
+
42
+ function addressList(a: AddressObject | AddressObject[] | undefined): string[] {
43
+ if (!a) return [];
44
+ const arr = Array.isArray(a) ? a : [a];
45
+ return arr.map((x) => x.text).filter((t): t is string => Boolean(t));
46
+ }
47
+
48
+ /** imapflow provides internalDate as either a Date or string depending on
49
+ * the code path. The string form is deliberately discarded (no date parsing
50
+ * in feature code, no-date-api); toRawInboundMessage then falls back to the
51
+ * mailparser Date header, which is always a real Date. */
52
+ export function coerceDate(d: Date | string | undefined): Date | undefined {
53
+ return d instanceof Date ? d : undefined;
54
+ }
55
+
56
+ /** providerMessageId: a UID is unique only within a UIDVALIDITY; together,
57
+ * they form the stable idempotency anchor. */
58
+ export function buildProviderMessageId(uidValidity: string, uid: number): string {
59
+ return `${uidValidity}:${uid}`;
60
+ }
61
+
62
+ export async function toRawInboundMessage(args: {
63
+ readonly source: Uint8Array;
64
+ readonly uid: number;
65
+ readonly uidValidity: string;
66
+ readonly internalDate: Date | undefined;
67
+ /** IMAP envelope; lets a mail that cannot be parsed still show sender and subject. */
68
+ readonly envelope?: MessageEnvelopeObject | undefined;
69
+ /** authserv-id of the mailbox's own server; without it senderAuthentication is null. */
70
+ readonly trustedAuthservId?: string | null;
71
+ }): Promise<RawInboundMessage> {
72
+ const parsed: ParsedMail = await simpleParser(Buffer.from(args.source));
73
+ const messageIdHeader = parsed.messageId ? stripAngles(parsed.messageId) : null;
74
+ const receivedAt = args.internalDate ?? parsed.date;
75
+ const content = await extractMailContent(parsed);
76
+ const text = parsed.text?.trim() ?? "";
77
+ return {
78
+ providerMessageId: buildProviderMessageId(args.uidValidity, args.uid),
79
+ messageIdHeader: messageIdHeader && messageIdHeader.length > 0 ? messageIdHeader : null,
80
+ providerThreadId: null,
81
+ references: normalizeReferences(parsed),
82
+ from: parsed.from?.text ?? "",
83
+ to: addressList(parsed.to),
84
+ cc: addressList(parsed.cc),
85
+ subject: parsed.subject ?? "",
86
+ snippet: text.slice(0, SNIPPET_MAX),
87
+ // Lib-boundary bridge (Date from imapflow/mailparser) → Temporal;
88
+ // when no date exists, Epoch is the honest marker.
89
+ receivedAtIso: (receivedAt
90
+ ? legacyDateToInstant(receivedAt)
91
+ : Temporal.Instant.fromEpochMilliseconds(0)
92
+ ).toString(),
93
+ text: content.text,
94
+ ...(content.html !== null && { html: content.html }),
95
+ attachments: content.attachments,
96
+ skippedAttachments: content.skipped,
97
+ senderAuthentication: senderAuthenticationOf(parsed, args.trustedAuthservId ?? null),
98
+ rawMime: args.source,
99
+ scope: "inbox",
100
+ };
101
+ }
102
+
103
+ function envelopeAddresses(list: MessageEnvelopeObject["to"]): string[] {
104
+ return (list ?? []).map((a) => a.address ?? "").filter((a) => a !== "");
105
+ }
106
+
107
+ function headersOfEnvelope(
108
+ envelope: MessageEnvelopeObject,
109
+ internalDate: Date | undefined,
110
+ ): NonNullable<RejectedInboundMessage["headers"]> {
111
+ const sender = envelope.from?.[0];
112
+ const receivedAt = internalDate ?? coerceDate(envelope.date);
113
+ return {
114
+ from: sender?.address ?? "",
115
+ to: envelopeAddresses(envelope.to),
116
+ cc: envelopeAddresses(envelope.cc),
117
+ subject: envelope.subject ?? "",
118
+ messageIdHeader: envelope.messageId ? stripAngles(envelope.messageId) : null,
119
+ receivedAtIso: receivedAt ? legacyDateToInstant(receivedAt).toString() : "",
120
+ };
121
+ }
122
+
123
+ export type ParsedOrRejected =
124
+ | { readonly message: RawInboundMessage }
125
+ | { readonly rejected: RejectedInboundMessage };
126
+
127
+ /** A mail the MIME parser cannot handle must not stop the mailbox: it is reported as rejected so the
128
+ * foundation quarantines it and the cursor moves on. */
129
+ export async function parseOrReject(
130
+ args: Parameters<typeof toRawInboundMessage>[0],
131
+ ): Promise<ParsedOrRejected> {
132
+ try {
133
+ return { message: await toRawInboundMessage(args) };
134
+ } catch (err) {
135
+ // Class name only: parser messages can quote header content.
136
+ log.warn("mail could not be parsed", {
137
+ uid: args.uid,
138
+ error: err instanceof Error ? err.name : typeof err,
139
+ });
140
+ return {
141
+ rejected: {
142
+ providerMessageId: buildProviderMessageId(args.uidValidity, args.uid),
143
+ reason: "unparseable",
144
+ rawMime: args.source,
145
+ ...(args.envelope && { headers: headersOfEnvelope(args.envelope, args.internalDate) }),
146
+ },
147
+ };
148
+ }
149
+ }
@@ -0,0 +1,45 @@
1
+ // Loads the mailbox a reconnect targets. The db is tenant-scoped, so another tenant's account id
2
+ // finds nothing. A disconnected mailbox is reconnectable on purpose: it keeps its id, threads and
3
+ // sync cursor, so reconnecting never re-imports history.
4
+
5
+ import {
6
+ InboundMailAuthMethods,
7
+ mailAccountsProjectionTable,
8
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
9
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
10
+ import {
11
+ failNotFound,
12
+ UnprocessableError,
13
+ type WriteFailure,
14
+ writeFailure,
15
+ } from "@cosmicdrift/kumiko-framework/errors";
16
+ import { IMAP_PROVIDER_KEY, IMAP_RECONNECT_ERRORS } from "./constants.ts";
17
+
18
+ type ReconnectAccountRow = {
19
+ readonly id: string;
20
+ readonly provider: string;
21
+ readonly authMethod: string;
22
+ readonly displayName: string | null;
23
+ readonly ownerUserId: string | null;
24
+ readonly status: string | null;
25
+ readonly trustedAuthservId: string | null;
26
+ };
27
+
28
+ export type ReconnectAccountLookup =
29
+ | { readonly isSuccess: true; readonly row: ReconnectAccountRow }
30
+ | WriteFailure;
31
+
32
+ export async function findReconnectableAccount(
33
+ db: Pick<TenantDb, "fetchOne">,
34
+ accountId: string,
35
+ ): Promise<ReconnectAccountLookup> {
36
+ const row = await db.fetchOne<ReconnectAccountRow>(mailAccountsProjectionTable, {
37
+ id: accountId,
38
+ });
39
+ if (!row) return failNotFound("mail-account", accountId);
40
+ if (row.provider !== IMAP_PROVIDER_KEY || row.authMethod !== InboundMailAuthMethods.password) {
41
+ const { reason, i18nKey } = IMAP_RECONNECT_ERRORS.notPasswordImap;
42
+ return writeFailure(new UnprocessableError(reason, { i18nKey, details: { accountId } }));
43
+ }
44
+ return { isSuccess: true, row };
45
+ }
@@ -0,0 +1,103 @@
1
+ // reconnect-mailbox: new credentials for an existing password IMAP mailbox, typically after the
2
+ // account went to auth_error. The login is checked before the transaction opens; only then the
3
+ // credential secret of the same account is overwritten and the account goes back to active (also from disconnected), both
4
+ // on the request transaction, so a failure leaves status and secret as they were.
5
+
6
+ import {
7
+ InboundMailFoundationHandlers,
8
+ type InboundMailRoles,
9
+ inboundCredentialSecretKey,
10
+ isMailAdmin,
11
+ mayReactivateMailbox,
12
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
13
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
14
+ import {
15
+ type BeforeTransactionResult,
16
+ defineWriteHandler,
17
+ } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
19
+ import * as z from "zod";
20
+ import {
21
+ CREDENTIAL_PREVIEW,
22
+ checkMailboxLogin,
23
+ connectMailboxSchema,
24
+ credentialDocumentOf,
25
+ } from "./connect-mailbox.ts";
26
+ import { findReconnectableAccount } from "./reconnect-account.ts";
27
+
28
+ const reconnectMailboxSchema = connectMailboxSchema
29
+ .pick({
30
+ host: true,
31
+ port: true,
32
+ user: true,
33
+ password: true,
34
+ smtpHost: true,
35
+ smtpPort: true,
36
+ trustedAuthservId: true,
37
+ })
38
+ .extend({ accountId: z.uuid() });
39
+
40
+ const SECRET_TRANSACTION_REASON =
41
+ "the credential secret is written on the request transaction so a failed reconnect rolls the account status back too";
42
+
43
+ export const createReconnectMailboxHandler = ({ memberRoles, adminRoles }: InboundMailRoles) =>
44
+ defineWriteHandler({
45
+ name: "reconnect-mailbox",
46
+ description:
47
+ "Replaces the credentials of an existing password IMAP mailbox: checks the IMAP login and the SMTP login of the outgoing server first, then overwrites the credential secret of the same account and sets it back to active, keeping its threads and sync cursor.",
48
+ // The payload carries the plaintext password: as a tool call it would reach the model provider.
49
+ agent: { expose: false },
50
+ schema: reconnectMailboxSchema,
51
+ // Members: the owner re-arms a personal mailbox; the lookup gate keeps shared ones to admins.
52
+ access: { roles: [...memberRoles] },
53
+ // Same cap as connect: each call opens a connection to a host the caller picks.
54
+ rateLimit: { per: "tenant+handler", limit: 10, windowSeconds: 600 },
55
+ escapeHatch: { grants: ["unsafeRaw"], reason: SECRET_TRANSACTION_REASON },
56
+ beforeTransaction: async (event, ctx): Promise<BeforeTransactionResult<null>> => {
57
+ const lookup = await findReconnectableAccount(ctx.db, event.payload.accountId);
58
+ if (!lookup.isSuccess) return lookup;
59
+ // Before the login check, so a denied caller cannot make the server probe a host; same answer as a missing account.
60
+ if (!mayReactivateMailbox(lookup.row.ownerUserId, ctx.user, adminRoles)) {
61
+ return failNotFound("mail-account", event.payload.accountId);
62
+ }
63
+ return checkMailboxLogin(credentialDocumentOf(event.payload), ctx.obs.log);
64
+ },
65
+ handler: async (event, ctx) => {
66
+ const { accountId } = event.payload;
67
+ // Secret first: reactivate from disconnected requires the credential to exist.
68
+ const secrets = requireSecretsContext(
69
+ { secrets: ctx.services.secrets, user: ctx.user },
70
+ "reconnect-mailbox",
71
+ ).withRunner(ctx.db.unsafeRaw());
72
+ await secrets.set(
73
+ event.user.tenantId,
74
+ inboundCredentialSecretKey(accountId),
75
+ JSON.stringify(credentialDocumentOf(event.payload)),
76
+ {
77
+ redact: () => CREDENTIAL_PREVIEW,
78
+ hint: `IMAP credentials for inbound mail account ${accountId}`,
79
+ updatedBy: event.user.id,
80
+ },
81
+ );
82
+ const reactivated = await ctx.dispatch.write(
83
+ InboundMailFoundationHandlers.reactivateAccount,
84
+ {
85
+ accountId,
86
+ },
87
+ );
88
+ if (!reactivated.isSuccess) return reactivated;
89
+ // The sender-check policy is tenant-wide: only admins set it, the owner's form does not carry it.
90
+ if (isMailAdmin(event.user, adminRoles)) {
91
+ // The admin form shows the stored id, so an empty field means the admin cleared it.
92
+ const trusted = await ctx.dispatch.write(
93
+ InboundMailFoundationHandlers.setTrustedAuthservId,
94
+ {
95
+ accountId,
96
+ trustedAuthservId: event.payload.trustedAuthservId ?? null,
97
+ },
98
+ );
99
+ if (!trusted.isSuccess) return trusted;
100
+ }
101
+ return { isSuccess: true, data: { accountId } };
102
+ },
103
+ });
@@ -0,0 +1,49 @@
1
+ // Initial values of the reconnect form: the server settings of the stored credential, never the
2
+ // password. A missing or unreadable credential leaves the fields empty for the admin to fill in.
3
+
4
+ import {
5
+ type InboundMailRoles,
6
+ inboundCredentialSecretKey,
7
+ mayReactivateMailbox,
8
+ } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
9
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
+ import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
11
+ import * as z from "zod";
12
+ import { FEATURE_NAME } from "./constants.ts";
13
+ import { parseImapCredentialDocument } from "./credential-document.ts";
14
+ import { findReconnectableAccount } from "./reconnect-account.ts";
15
+
16
+ const reconnectPrefillSchema = z.object({ accountId: z.uuid() }).strict();
17
+
18
+ export const createReconnectPrefillQuery = ({
19
+ memberRoles,
20
+ adminRoles,
21
+ }: InboundMailRoles): QueryHandlerDef => ({
22
+ name: "reconnect-prefill",
23
+ schema: reconnectPrefillSchema,
24
+ access: { roles: [...memberRoles] },
25
+ // Only the reconnect form needs these settings; the agent has no write to use them with.
26
+ agent: { expose: false },
27
+ description:
28
+ "Returns the server settings (IMAP host and port, user name, SMTP host and port, trusted authserv-id) stored for a password IMAP mailbox without its password, or null when the mailbox is not found or not a password IMAP mailbox; use it to prefill the reconnect form.",
29
+ handler: async (query, ctx) => {
30
+ const { accountId } = reconnectPrefillSchema.parse(query.payload);
31
+ const lookup = await findReconnectableAccount(ctx.db, accountId);
32
+ // Same null as for a missing account: a caller who may not reconnect it learns nothing about it.
33
+ if (!lookup.isSuccess || !mayReactivateMailbox(lookup.row.ownerUserId, ctx.user, adminRoles)) {
34
+ return null;
35
+ }
36
+
37
+ const secrets = requireSecretsContext(
38
+ { secrets: ctx.services.secrets, user: ctx.user },
39
+ FEATURE_NAME,
40
+ );
41
+ const stored = await secrets.get(ctx.user.tenantId, inboundCredentialSecretKey(accountId));
42
+ const parsed = stored ? parseImapCredentialDocument(stored.reveal()) : undefined;
43
+ const displayName = lookup.row.displayName ?? "";
44
+ const trustedAuthservId = lookup.row.trustedAuthservId ?? "";
45
+ if (!parsed?.ok) return { accountId, displayName, trustedAuthservId };
46
+ const { host, port, user, smtpHost, smtpPort } = parsed.doc;
47
+ return { accountId, displayName, host, port, user, smtpHost, smtpPort, trustedAuthservId };
48
+ },
49
+ });
@@ -1,52 +1,219 @@
1
1
  import {
2
2
  IMAP_CONNECT_SCREEN_ID,
3
+ IMAP_RECONNECT_SCREEN_ID,
4
+ type InboundMailRoles,
3
5
  MAIL_ACCOUNT_LIST_SCREEN_QN,
6
+ MY_IMAP_CONNECT_SCREEN_ID,
7
+ MY_IMAP_RECONNECT_SCREEN_ID,
8
+ MY_MAIL_ACCOUNT_LIST_SCREEN_QN,
4
9
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
5
10
  import { i18nKey } from "@cosmicdrift/kumiko-framework/engine";
6
11
  import type { ActionFormScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { ImapHandlers, SMTP_SUBMISSION_PORT } from "./constants.ts";
12
+ import { ImapHandlers, ImapQueries, SMTP_SUBMISSION_PORT } from "./constants.ts";
8
13
 
9
- // Reached from the toolbar of the foundation's mail account list, never from the nav.
10
- export const imapConnectScreen: ActionFormScreenDefinition = {
11
- id: IMAP_CONNECT_SCREEN_ID,
12
- type: "actionForm",
13
- agent: { expose: false },
14
- handler: ImapHandlers.connectMailbox,
15
- fields: {
16
- displayName: { type: "text", required: true, maxLength: 200 },
17
- address: { type: "text", required: true, maxLength: 320, format: "email" },
18
- host: { type: "text", required: true, maxLength: 255 },
19
- port: { type: "number", integer: true, required: true, min: 1, max: 65535, default: 993 },
20
- user: { type: "text", required: true, maxLength: 320 },
21
- password: { type: "text", required: true, format: "password", sensitive: true },
22
- smtpHost: { type: "text", maxLength: 255 },
23
- smtpPort: {
24
- type: "number",
25
- integer: true,
26
- required: true,
27
- min: 1,
28
- max: 65535,
29
- default: SMTP_SUBMISSION_PORT,
14
+ // The admin form connects a shared mailbox, the member form a personal one. The member form carries
15
+ // no sender-check field because that policy is tenant-wide and only admins set it.
16
+ function createConnectScreen(variant: {
17
+ readonly id: string;
18
+ readonly handler: string;
19
+ readonly roles: readonly string[];
20
+ readonly returnTarget: string;
21
+ readonly withSenderCheck: boolean;
22
+ }): ActionFormScreenDefinition {
23
+ return {
24
+ id: variant.id,
25
+ type: "actionForm",
26
+ agent: { expose: false },
27
+ handler: variant.handler,
28
+ fields: {
29
+ displayName: { type: "text", required: true, maxLength: 200 },
30
+ address: { type: "text", required: true, maxLength: 320, format: "email" },
31
+ host: { type: "text", required: true, maxLength: 255 },
32
+ port: {
33
+ type: "number",
34
+ integer: true,
35
+ grouping: false,
36
+ required: true,
37
+ min: 1,
38
+ max: 65535,
39
+ default: 993,
40
+ },
41
+ user: { type: "text", required: true, maxLength: 320 },
42
+ password: { type: "text", required: true, format: "password", sensitive: true },
43
+ smtpHost: { type: "text", maxLength: 255 },
44
+ smtpPort: {
45
+ type: "number",
46
+ integer: true,
47
+ grouping: false,
48
+ required: true,
49
+ min: 1,
50
+ max: 65535,
51
+ default: SMTP_SUBMISSION_PORT,
52
+ },
53
+ ...(variant.withSenderCheck && { trustedAuthservId: { type: "text", maxLength: 255 } }),
54
+ },
55
+ layout: {
56
+ sections: [
57
+ // The name-based icon guess reads "address" as a postal address.
58
+ { fields: ["displayName", { field: "address", icon: "mail" }] },
59
+ {
60
+ title: i18nKey("inboundMail.imap.connect.server"),
61
+ fields: ["host", "port", "user", "password"],
62
+ },
63
+ {
64
+ title: i18nKey("inboundMail.imap.connect.outgoing"),
65
+ description: i18nKey("inboundMail.imap.connect.outgoingHint"),
66
+ fields: ["smtpHost", "smtpPort"],
67
+ },
68
+ ...(variant.withSenderCheck
69
+ ? [
70
+ {
71
+ title: i18nKey("inboundMail.imap.connect.senderCheck"),
72
+ description: i18nKey("inboundMail.imap.connect.senderCheckHint"),
73
+ fields: ["trustedAuthservId"],
74
+ },
75
+ ]
76
+ : []),
77
+ ],
30
78
  },
31
- },
32
- layout: {
33
- sections: [
34
- // The name-based icon guess reads "address" as a postal address.
35
- { fields: ["displayName", { field: "address", icon: "mail" }] },
36
- {
37
- title: i18nKey("inboundMail.imap.connect.server"),
38
- fields: ["host", "port", "user", "password"],
79
+ submitLabel: i18nKey("inboundMail.imap.connect.submit"),
80
+ redirect: variant.returnTarget,
81
+ cancelTarget: variant.returnTarget,
82
+ access: { roles: [...variant.roles] },
83
+ dormant: true,
84
+ };
85
+ }
86
+
87
+ // Reached from the toolbar of the foundation's mail account list, never from the nav.
88
+ export const createImapConnectScreen = ({
89
+ adminRoles,
90
+ }: InboundMailRoles): ActionFormScreenDefinition =>
91
+ createConnectScreen({
92
+ id: IMAP_CONNECT_SCREEN_ID,
93
+ handler: ImapHandlers.connectMailbox,
94
+ roles: adminRoles,
95
+ returnTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
96
+ withSenderCheck: true,
97
+ });
98
+
99
+ // Reached from the toolbar of "My mail accounts".
100
+ export const createMyImapConnectScreen = ({
101
+ memberRoles,
102
+ }: InboundMailRoles): ActionFormScreenDefinition =>
103
+ createConnectScreen({
104
+ id: MY_IMAP_CONNECT_SCREEN_ID,
105
+ handler: ImapHandlers.connectMyMailbox,
106
+ roles: memberRoles,
107
+ returnTarget: MY_MAIL_ACCOUNT_LIST_SCREEN_QN,
108
+ withSenderCheck: false,
109
+ });
110
+
111
+ // Reached from the "Reconnect" row action of an account with a sign-in error. The record id comes
112
+ // from the path; the load query returns the stored server settings, never the password.
113
+ // The admin and the owner variant share this form; the owner's carries no sender-check field
114
+ // because that policy is tenant-wide and only admins set it.
115
+ function createReconnectScreen(variant: {
116
+ readonly id: string;
117
+ readonly roles: readonly string[];
118
+ readonly returnTarget: string;
119
+ readonly withSenderCheck: boolean;
120
+ }): ActionFormScreenDefinition {
121
+ return {
122
+ id: variant.id,
123
+ type: "actionForm",
124
+ agent: { expose: false },
125
+ handler: ImapHandlers.reconnectMailbox,
126
+ load: { query: ImapQueries.reconnectPrefill, idParam: "accountId" },
127
+ fields: {
128
+ accountId: { type: "text", required: true },
129
+ displayName: { type: "text", maxLength: 200 },
130
+ host: { type: "text", required: true, maxLength: 255 },
131
+ port: {
132
+ type: "number",
133
+ integer: true,
134
+ grouping: false,
135
+ required: true,
136
+ min: 1,
137
+ max: 65535,
138
+ default: 993,
39
139
  },
40
- {
41
- title: i18nKey("inboundMail.imap.connect.outgoing"),
42
- description: i18nKey("inboundMail.imap.connect.outgoingHint"),
43
- fields: ["smtpHost", "smtpPort"],
140
+ user: { type: "text", required: true, maxLength: 320 },
141
+ password: { type: "text", required: true, format: "password", sensitive: true },
142
+ smtpHost: { type: "text", maxLength: 255 },
143
+ smtpPort: {
144
+ type: "number",
145
+ integer: true,
146
+ grouping: false,
147
+ required: true,
148
+ min: 1,
149
+ max: 65535,
150
+ default: SMTP_SUBMISSION_PORT,
44
151
  },
45
- ],
46
- },
47
- submitLabel: i18nKey("inboundMail.imap.connect.submit"),
48
- redirect: MAIL_ACCOUNT_LIST_SCREEN_QN,
49
- cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
50
- access: { roles: ["SystemAdmin", "TenantAdmin"] },
51
- dormant: true,
52
- };
152
+ ...(variant.withSenderCheck && { trustedAuthservId: { type: "text", maxLength: 255 } }),
153
+ },
154
+ layout: {
155
+ sections: [
156
+ {
157
+ title: i18nKey("inboundMail.imap.connect.server"),
158
+ description: i18nKey("inboundMail.imap.reconnect.hint"),
159
+ // The id only travels in the payload; the user recognises the mailbox by its name.
160
+ fields: [
161
+ { field: "accountId", visible: false },
162
+ { field: "displayName", readOnly: true, submit: false },
163
+ "host",
164
+ "port",
165
+ "user",
166
+ "password",
167
+ ],
168
+ },
169
+ {
170
+ title: i18nKey("inboundMail.imap.connect.outgoing"),
171
+ description: i18nKey("inboundMail.imap.connect.outgoingHint"),
172
+ fields: ["smtpHost", "smtpPort"],
173
+ },
174
+ ...(variant.withSenderCheck
175
+ ? [
176
+ {
177
+ title: i18nKey("inboundMail.imap.connect.senderCheck"),
178
+ description: i18nKey("inboundMail.imap.connect.senderCheckHint"),
179
+ fields: ["trustedAuthservId"],
180
+ },
181
+ ]
182
+ : []),
183
+ ],
184
+ },
185
+ submitLabel: i18nKey("inboundMail.imap.reconnect.submit"),
186
+ redirect: variant.returnTarget,
187
+ cancelTarget: variant.returnTarget,
188
+ access: { roles: [...variant.roles] },
189
+ dormant: true,
190
+ };
191
+ }
192
+
193
+ export const createImapReconnectScreen = ({
194
+ adminRoles,
195
+ }: InboundMailRoles): ActionFormScreenDefinition =>
196
+ createReconnectScreen({
197
+ id: IMAP_RECONNECT_SCREEN_ID,
198
+ roles: adminRoles,
199
+ returnTarget: MAIL_ACCOUNT_LIST_SCREEN_QN,
200
+ withSenderCheck: true,
201
+ });
202
+
203
+ // Reached from the "Reactivate" row action of "My mail accounts".
204
+ export const createMyImapReconnectScreen = ({
205
+ memberRoles,
206
+ }: InboundMailRoles): ActionFormScreenDefinition =>
207
+ createReconnectScreen({
208
+ id: MY_IMAP_RECONNECT_SCREEN_ID,
209
+ roles: memberRoles,
210
+ returnTarget: MY_MAIL_ACCOUNT_LIST_SCREEN_QN,
211
+ withSenderCheck: false,
212
+ });
213
+
214
+ export const createImapScreens = (roles: InboundMailRoles): ActionFormScreenDefinition[] => [
215
+ createImapConnectScreen(roles),
216
+ createImapReconnectScreen(roles),
217
+ createMyImapConnectScreen(roles),
218
+ createMyImapReconnectScreen(roles),
219
+ ];
@@ -9,8 +9,9 @@ import {
9
9
  type MailAccountRecord,
10
10
  MailSendNotConfiguredError,
11
11
  MailSendRejectedError,
12
+ type OutboundAttachment,
12
13
  type OutboundMailMessage,
13
- type OutboundMailSent,
14
+ type ProviderSentMail,
14
15
  } from "@cosmicdrift/kumiko-bundled-features/inbound-mail-foundation";
15
16
  import MailComposer from "nodemailer/lib/mail-composer";
16
17
  import { FEATURE_NAME } from "./constants.ts";
@@ -37,7 +38,8 @@ export async function sendFromImapAccount(
37
38
  ctx: InboundMailContext,
38
39
  account: MailAccountRecord,
39
40
  message: OutboundMailMessage,
40
- ): Promise<OutboundMailSent> {
41
+ attachments: readonly OutboundAttachment[],
42
+ ): Promise<ProviderSentMail> {
41
43
  const doc = await readCredentialDocument(ctx, account);
42
44
  if (!doc.smtpHost || !doc.password) {
43
45
  throw new MailSendNotConfiguredError(
@@ -55,6 +57,13 @@ export async function sendFromImapAccount(
55
57
  ...(message.html !== undefined && { html: message.html }),
56
58
  ...(message.inReplyTo && { inReplyTo: message.inReplyTo }),
57
59
  ...(message.references && { references: [...message.references] }),
60
+ ...(attachments.length > 0 && {
61
+ attachments: attachments.map((attachment) => ({
62
+ filename: attachment.fileName,
63
+ contentType: attachment.mimeType,
64
+ content: Buffer.from(attachment.data),
65
+ })),
66
+ }),
58
67
  }).compile();
59
68
  const raw = await mime.build();
60
69