@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
|
@@ -1,4 +1,159 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.394.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Imported mails store full text, original and attachments",
|
|
6
|
+
"detail": "ingest-message now takes bodyText (at most 32,000 characters, cut with bodyTextTruncated set; 32,000 keeps a long mail readable while one event, one projection row and the PII-encrypted column stay small, the original keeps everything), rawMime, bodyHtml, attachments (at most 50) and skippedAttachments (at most 200). The original is stored as .eml (or .html for HTML-only mails) and each attachment as a file reference through file storage; apps without a file provider still import the mail, mark contentState unavailable and show the mailbox as interrupted. The event inbound-message-received is now v2 with an upcast that defaults the new fields; bodyText is personal tenant data. The new query inbound-mail-foundation:query:message:content returns text, original reference and attachments of one mail; message:list never carries the body. Retention removes the mail row, its body object and its attachment files. RawInboundMessage gains text, html, attachments and skippedAttachments."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.394.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "A mail that cannot be parsed is quarantined instead of blocking the mailbox",
|
|
12
|
+
"detail": "A mail the provider reports as rejected, or whose ingest fails, is normalized where possible and otherwise stored in the unmanaged table store_mail_quarantine; every message is isolated in the supervisor, so the rest of the batch imports and the sync cursor moves on. An ingest failure only counts against a mail once a later mail of the batch succeeded, and the third counted failure quarantines it as ingest_failed; an outage of the database, KMS or file storage never quarantines good mail and never moves the cursor past an unhandled mail. The account list shows quarantinedCount and lastError, the new handler acknowledge-quarantine confirms the entries, acknowledged entries are purged by retention after 90 days, and the tenant-destroy hook removes them."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.394.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "A mailbox that stops syncing shows as interrupted with a reason",
|
|
18
|
+
"detail": "The account events mail-account-connected and mail-account-updated are v2 with lastError (connection_failed, rate_limited, message_quarantined, file_storage_unavailable). Three failed polls in a row set the status degraded, shown as Unterbrochen in German; the next successful poll sets it back to active. New key in all locale packages."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.394.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "A disconnected mailbox is reactivated under the same account",
|
|
24
|
+
"detail": "The new handler reactivate-account puts a disconnected mailbox back to active with its stream and sync cursor, so no mail is imported twice. connect-account refuses an address that is already connected (address_already_connected) or disconnected (address_disconnected) and reopens a disconnected OAuth mailbox in place; connect-routes answers 409 with the reason. reconnect-mailbox now also accepts a disconnected account."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.394.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "Snippet-only mails get their content reloaded",
|
|
30
|
+
"detail": "Mails imported before this change carry only a snippet. The supervisor asks the provider for their content through fetchContent and stores it with attach-message-content (event inbound-message-content-fetched v1); a mail the server no longer has becomes unavailable and is not asked for again."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.394.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "Connected, reconnected and reactivated mailboxes sync immediately",
|
|
36
|
+
"detail": "A per-instance event consumer wakes the supervisor on a new or active-again mailbox, which syncs once right away instead of waiting for the next poll. A sync of one account never runs twice at the same time. A woken sync that finds the credential not stored yet (the OAuth secret is written after the account) sets no status and leaves it to the next tick; stop() waits for running syncs."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.394.0",
|
|
40
|
+
"type": "improvement",
|
|
41
|
+
"title": "The first sync after an OAuth connect no longer waits for the next poll",
|
|
42
|
+
"detail": "The OAuth callback now dispatches the internal handler mark-credential-stored once the refresh token is saved, so the sync that the account commit triggers earlier than the token is repeated as soon as the credential exists. Originals above 50 MB are not stored; they are listed in skippedAttachments as message/rfc822 with reason too_large while text and attachments are kept. attach-message-content no longer accepts a caller-supplied bodyRef."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.394.0",
|
|
46
|
+
"type": "improvement",
|
|
47
|
+
"title": "Outgoing mail carries attachments",
|
|
48
|
+
"detail": "sendMailFromAccount takes attachmentFileIds (at most 10 file references of the caller's tenant, at most 18 MB together, duplicates dropped). The bytes are read through file storage and sent over SMTP and stored in the sent folder unchanged; an unknown or foreign file gives mail_send_attachment_invalid, too many bytes mail_send_attachments_too_large, and nothing is sent. The plugin method send now receives the resolved attachments as a fourth argument and returns ProviderSentMail. New keys in all locale packages."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.394.0",
|
|
52
|
+
"type": "improvement",
|
|
53
|
+
"title": "A hanging mailbox no longer blocks the others",
|
|
54
|
+
"detail": "The supervisor syncs up to 4 accounts at the same time (pollConcurrency), skips an account whose previous sync is still running, and abandons a sync after 10 minutes (accountPollDeadlineMs) as one failed poll; plugin.fetch and plugin.fetchContent receive an AbortSignal, and an abandoned sync stores nothing and moves no cursor. The IMAP client sets connection (20 s) and greeting (16 s) timeouts explicitly."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "0.394.0",
|
|
58
|
+
"type": "improvement",
|
|
59
|
+
"title": "Sender authentication (SPF, DKIM, DMARC) of imported mails",
|
|
60
|
+
"detail": "inbound-message-received v2 carries senderAuthentication ({ authservId, spf, dkim, dkimDomains, dmarc, fromDomain } or null; verdicts pass, fail, none, unknown; dkimDomains at most 10), stored as JSON text in read_inbound_messages.sender_authentication and returned by message:content and loadInboundMessageContent; isSenderAuthenticated is true only for a dmarc pass. The mail account has a new optional trustedAuthservId (events mail-account-* v2, read_mail_accounts.trusted_authserv_id, input of connect-account, update-account, reactivate-account and the IMAP connect and reconnect forms). Only the first Authentication-Results header from the top whose authserv-id equals it counts; without an id, without such a header or when its header.from domain differs from the From domain the result is null, and a dmarc result without header.from is unknown. Headers below the trusted one are ignored, so a sender cannot inject a pass. Mails imported before, and content refetched later, have null. New keys in all locale packages."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"version": "0.394.0",
|
|
64
|
+
"type": "breaking",
|
|
65
|
+
"title": "Mail account handlers are stricter and the trusted authserv-id has its own handler",
|
|
66
|
+
"detail": "trustedAuthservId is no longer accepted by connect-account, update-account and reactivate-account (strict schemas reject unknown keys); set it with the new set-trusted-authserv-id (SystemAdmin, TenantAdmin, not model-callable). update-account accepts status, lastError, watchState and reason only from the system identity; connect-account is no longer exposed to agents. reactivate-account checks the account owner (unless the OAuth override is set), fails with credential_required when a password account has no stored credential, and reopens an account in place only with that override; disconnect-account deletes the stored secret. Outgoing attachments must pass the file read check of the sender, and SendMailFromAccountContext needs dispatch. New screen \"Absenderprüfung\" edits the id. The watch supervisor and the IMAP forms use mapWithConcurrency and implicit TLS on port 465 for SMTPS.",
|
|
67
|
+
"migration": "Drop trustedAuthservId from connect-account, update-account and reactivate-account payloads and dispatch set-trusted-authserv-id after connecting. Wrapping or agent setups that called connect-account as a model tool need their own exposed handler. Callers of sendMailFromAccount pass a context that includes dispatch. Personal mailboxes are reactivated by their owner only; after disconnect-account a new credential must be stored before reactivate-account."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"version": "0.394.0",
|
|
71
|
+
"type": "breaking",
|
|
72
|
+
"title": "The mailbox row action Reconnect is folded into Reactivate",
|
|
73
|
+
"detail": "The row action reconnect of mailAccountListScreen and its i18n key inboundMail.accounts.reconnect are removed. A password IMAP mailbox with a sign-in error now shows Reactivate, which opens the same screen mail-account-reconnect-imap; OAuth mailboxes get the client action reactivate-oauth instead.",
|
|
74
|
+
"migration": "Apps that override inboundMail.accounts.reconnect move the text to inboundMail.accounts.reactivate. Apps that reference the row action id reconnect (tests, screen extensions) use reactivate for password IMAP and reactivate-oauth for OAuth mailboxes, and mount inboundMailFoundationClient() from @cosmicdrift/kumiko-bundled-features/inbound-mail-foundation/web so the OAuth action runs."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"version": "0.394.0",
|
|
78
|
+
"type": "breaking",
|
|
79
|
+
"title": "The OAuth callback only connects a personal mailbox for the account that signed in",
|
|
80
|
+
"detail": "InboundOAuthFlow.exchangeCode returns OAuthCodeExchange, an OAuthTokenSet with the required accountAddress of the signed-in provider account. The callback compares it with the mailbox address, ignoring case, and answers 403 mailbox_address_mismatch with a plain-text message and no account when they differ, so nobody can claim another person's address. This applies to personal mailboxes; a shared mailbox (only admins start that flow) is often opened with the admin's own login through delegated access, as with M365 shared mailboxes, so its address is not compared, and a token without access to it shows auth_error on the first sync.",
|
|
81
|
+
"migration": "Inbound providers with an oauth block return accountAddress from exchangeCode, taken from the token's email claim or the provider's profile endpoint (for example the userinfo email). There is no default: a provider that cannot name the signed-in address cannot connect mailboxes."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"version": "0.394.0",
|
|
85
|
+
"type": "improvement",
|
|
86
|
+
"title": "Failing ingests are counted and a poison mail is quarantined after three intervals",
|
|
87
|
+
"detail": "The supervisor counts an ingest failure once per poll interval in the unmanaged table store_mail_ingest_failures, but only when another mail of the account went through in the same poll (for a storage failure another storage write); at three it quarantines the mail with reason ingest_failed and moves the cursor on, before that the batch stops and nothing behind it is skipped. A failure nothing proves to be the mail's (KMS, file storage, the app's storeBody hook, the ingest handler or projection failing for every mail) counts nothing (when every failure of a poll was counted before as a storage or ingest failure, they all keep counting), keeps the cursor and sets the account to degraded with lastError encryption_unavailable, file_storage_unavailable or ingest_unavailable; the next interval retries. An account the supervisor cannot poll because the key store fails while decrypting its address is set to encryption_unavailable too, instead of being skipped silently. A mail that waits for its count clears a shown encryption_unavailable or ingest_unavailable, and file_storage_unavailable once a storage write went through. A requested quarantine retry also runs while a failing mail holds the cursor; during an outage, and when a reload fails because of the provider, the KMS or file storage, the request stays and runs after the recovery. Every quarantined mail also gets a header-only entry (contentState unavailable, contentUnavailableReason quarantined); after a storage failure it goes without the original."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"version": "0.394.0",
|
|
91
|
+
"type": "improvement",
|
|
92
|
+
"title": "Quarantined mails can be retried",
|
|
93
|
+
"detail": "The write handler retry-quarantined-message (mailbox owner or admin) flags a quarantine entry; the next successful sync loads the mail again, attaches the content to the header entry or ingests it in full, and removes the entry."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"version": "0.394.0",
|
|
97
|
+
"type": "breaking",
|
|
98
|
+
"title": "inbound-message-received is v3 and inbound-message-content-fetched is v2",
|
|
99
|
+
"detail": "Both events carry contentUnavailableReason (null for older events through upcasters). The read table read_inbound_messages gets content_unavailable_reason, store_mail_quarantine gets retry_requested_at, and store_mail_ingest_failures is new; generate and apply a migration with `kumiko-schema generate`. ingest-message and attach-message-content upload files before their transaction when called at top level, and attach-message-content skips the upload when the mail already shows its content; inside a wrapper handler's transaction they upload in it, as before. RejectedInboundMessage may carry rawMime and headers, and an inbound provider's fetch may take filterUnknownMessageIds to skip mails that are already known.",
|
|
100
|
+
"migration": "Run `kumiko-schema generate` and apply the migration; read_inbound_messages is rebuilt from events because attachment_file_ids becomes jsonb. Consumers that read the event payloads type them against the new versions (upcasters fill contentUnavailableReason with null). To get the pre-transaction upload, trigger on inbound-message-received or inbound-message-content-fetched instead of wrapping ingest-message in an own write handler."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"version": "0.394.0",
|
|
104
|
+
"type": "improvement",
|
|
105
|
+
"title": "A watch that starts during a sync catches up on mail that arrived before it opened",
|
|
106
|
+
"detail": "After a successful watch start the supervisor runs one catch-up poll (after a running sync finished), and a tick also starts the watch while the account is still syncing. Mail delivered between the last poll and the watch opening is imported without waiting for the poll interval. The watch start is idempotent per account."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"version": "0.394.0",
|
|
110
|
+
"type": "improvement",
|
|
111
|
+
"title": "Exports for consumers of content events and attachment names",
|
|
112
|
+
"detail": "INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN, its payload type and schema, and readableFileName({ id, fileName }) are exported from the feature index, so an app can react to fetched content and show decrypted attachment names."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"version": "0.394.0",
|
|
116
|
+
"type": "improvement",
|
|
117
|
+
"title": "Reactivate a disconnected mailbox from the mailbox list",
|
|
118
|
+
"detail": "list-accounts returns reactivateVia per mailbox (password_form, oauth_flow or null) for a disconnected mailbox or one with a sign-in error, set only for the owner of a personal mailbox and for admins of a shared one. The mailbox list shows Reactivate for password_form; the new dormant screen my-mail-accounts (MY_MAIL_ACCOUNT_LIST_SCREEN_ID, role User) and the admin list show Reactivate for oauth_flow as the client action inbound-mail-foundation:reconnect-oauth, which goes through GET /api/inbound-mail/connect and the callback again and reopens the mailbox under its old id; apps mount it with inboundMailFoundationClient() from inbound-mail-foundation/web. The callback also reopens an OAuth mailbox with a sign-in error. It switches a reopened mailbox back on only after the new refresh token is stored; when storing fails it answers 502 credential_store_failed with a plain-text message and the mailbox keeps its previous state. reactivate-account admits the system user for this step. reactivate-account admits role User, gated by the owner rule for personal mailboxes, and requires a credential for disconnected, auth_error and degraded alike. set-trusted-authserv-id is a tenant security policy: only SystemAdmin and TenantAdmin change it, on every mailbox of the tenant including personal ones. The connect route answers 403 shared_mailbox_requires_admin when a user without admin role starts the shared flow. The row action Reconnect is folded into Reactivate (see the breaking entry). The connect-mailbox form reopens a disconnected shared IMAP mailbox of the same address under its old account id with the new password. For anything else connect-account answers with a text pointing to Reactivate, and credential_required from reactivate-account is a plain-text message. A new i18n key set (de, en, fr, es, it) is part of the locale packages."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"version": "0.394.0",
|
|
122
|
+
"type": "improvement",
|
|
123
|
+
"title": "Quarantined mails are listed with sender and subject and handled one by one",
|
|
124
|
+
"detail": "The query quarantine:list and the dormant screen mail-quarantine show the open entries of the mailboxes the caller may manage, with sender and subject from the header-only import (empty without one), reason and date; row actions fetch a mail again or confirm it. acknowledge-quarantine takes an optional providerMessageId (without it all entries of the mailbox as before) and now also admits the owner of a personal mailbox (role User). The mailbox list gets a row action Quarantine for mailboxes with open entries."
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"version": "0.394.0",
|
|
128
|
+
"type": "improvement",
|
|
129
|
+
"title": "Mail attachments are bound to the mail and follow its visibility",
|
|
130
|
+
"detail": "The attachment fileRefs of an imported mail are bound to the inbound-message record (field attachmentFileIds). GET /files/:id answers with the mail's visibility: owner and admins for a personal mailbox, whoever may see the mail for a shared one. The new query inbound-message:detail ({ id }) backs that check and is not exposed to agents. Outbound attachments use the same rule (canCallerReadFileRef). Retention now also removes the attachment files of a purged mail. read_inbound_messages.attachment_file_ids is jsonb; the migration 0056 carries a table rebuild marker, so the read table is rebuilt from the events. InboundMessageContent.attachmentFileIds stays string[]."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"version": "0.394.0",
|
|
134
|
+
"type": "breaking",
|
|
135
|
+
"title": "Personal mail is private to its owner, roles are configurable",
|
|
136
|
+
"detail": "Admins no longer read messages, bodies, attachments or quarantine of a personal mailbox; list-accounts still shows them the account without counts. The screens and the IMAP handlers are factories (createMailAccountListScreen, createImapConnectScreen and others) taking the resolved roles instead of constants. The content-fetched event is version 3 with accountId and ownerUserId. createDefaultGuard has a fourth parameter recordOnlyEntityTypes; for those entity types the files privilegedRoles no longer read or delete attached files (shared mailboxes included), only the uploader and readers of the record do. The OAuth connect route answers 403 mail_role_required to callers without a mail role.",
|
|
137
|
+
"migration": "Apps that import mailAccountListScreen, mailAccountEditScreen, mailAccountSenderCheckScreen, myMailAccountListScreen, mailQuarantineScreen, imapConnectScreen or imapReconnectScreen call the matching create*Screen factory with resolveInboundMailRoles(). Apps with their own roles pass { userRoles, adminRoles } to createInboundMailFoundationFeature and the same options to createInboundProviderImapFeature. Callers of createDefaultGuard pass a Set of record-only entity types as fourth argument. Code that read personal mail as an admin must run as the owner; the nullable accountId and ownerUserId on content-fetched events need no action."
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"version": "0.394.0",
|
|
141
|
+
"type": "fix",
|
|
142
|
+
"title": "Supervisor and OAuth callback writes work when the app roles leave out SystemAdmin",
|
|
143
|
+
"detail": "`update-account`, `connect-account` and `reactivate-account` are dispatched as the system user by the watch supervisor, the account health and the OAuth callback, but their access list held only the configured `adminRoles`/`memberRoles`. An app whose roles exclude SystemAdmin saw every health, watch-state and callback write refused (\"health update was rejected\"), so mailboxes never showed `degraded`. These three handlers now also accept SystemAdmin (the list is `SYSTEM_DISPATCHED_MAIL_HANDLERS`). `isMailAdmin`, screens and what real users may do stay on the configured roles."
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"version": "0.393.0",
|
|
147
|
+
"type": "improvement",
|
|
148
|
+
"title": "The mail account list offers Reconnect for accounts with a sign-in error",
|
|
149
|
+
"detail": "A new row action Reconnect opens the screen mail-account-reconnect-imap of inbound-provider-imap. It shows for every account with status auth_error and, like the Connect mailbox button, only renders in apps that mount that provider. New export IMAP_RECONNECT_SCREEN_ID. New key in all locale packages."
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
"version": "0.393.0",
|
|
153
|
+
"type": "fix",
|
|
154
|
+
"title": "Mailbox names are stored without surrounding spaces",
|
|
155
|
+
"detail": "connect-account and update-account trim displayName before storing it, so a name pasted with a leading space no longer shows and sorts with it. connect-account still accepts an empty name as before."
|
|
156
|
+
},
|
|
2
157
|
{
|
|
3
158
|
"version": "0.392.2",
|
|
4
159
|
"type": "fix",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { SignatureExtraRouteVerifyRequest } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { ExtraRouteRejection } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import { errorJson } from "./connect-route-responses.ts";
|
|
5
|
+
import { normalizeMailAddress } from "./handlers/account-address.ts";
|
|
6
|
+
import type { OAuthCodeExchange } from "./inbound-oauth-flow.ts";
|
|
7
|
+
import { type OAuthStatePayload, verifyOAuthState } from "./oauth-state.ts";
|
|
8
|
+
|
|
9
|
+
export type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
|
|
10
|
+
|
|
11
|
+
export function verifyCallbackRequest(
|
|
12
|
+
request: SignatureExtraRouteVerifyRequest,
|
|
13
|
+
stateSecret: string,
|
|
14
|
+
): VerifiedCallback {
|
|
15
|
+
const code = request.query["code"] ?? "";
|
|
16
|
+
const rawState = request.query["state"] ?? "";
|
|
17
|
+
if (!code || !rawState) {
|
|
18
|
+
throw new ExtraRouteRejection(
|
|
19
|
+
400,
|
|
20
|
+
{ error: { code: "invalid_callback", message: "missing code or state" } },
|
|
21
|
+
"inbound-mail oauth callback missing code or state",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
const verified = verifyOAuthState(rawState, stateSecret);
|
|
25
|
+
if (!verified.ok) {
|
|
26
|
+
throw new ExtraRouteRejection(
|
|
27
|
+
400,
|
|
28
|
+
{ error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
|
|
29
|
+
`inbound-mail oauth callback state rejected: ${verified.reason}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return { code, state: verified.payload };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The refresh token the account needs, or the response that refuses the exchange result.
|
|
36
|
+
export function refreshTokenOrRefusal(
|
|
37
|
+
c: Context,
|
|
38
|
+
state: OAuthStatePayload,
|
|
39
|
+
tokens: OAuthCodeExchange,
|
|
40
|
+
): string | Response {
|
|
41
|
+
// Personal only: a shared mailbox is often an M365 shared mailbox the admin opens with their own
|
|
42
|
+
// login (delegated access). A token without access to it shows up as auth_error on the first sync.
|
|
43
|
+
if (
|
|
44
|
+
state.ownerUserId &&
|
|
45
|
+
normalizeMailAddress(tokens.accountAddress) !== normalizeMailAddress(state.mailbox)
|
|
46
|
+
) {
|
|
47
|
+
return errorJson(
|
|
48
|
+
c,
|
|
49
|
+
403,
|
|
50
|
+
"mailbox_address_mismatch",
|
|
51
|
+
`the account you signed in with is not ${state.mailbox}; sign in with that mailbox's account`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
// No refresh token, no server-side sync. Consent must request offline_access (or
|
|
55
|
+
// access_type=offline); the provider's oauth block owns the scopes.
|
|
56
|
+
if (!tokens.refreshToken) {
|
|
57
|
+
return errorJson(
|
|
58
|
+
c,
|
|
59
|
+
502,
|
|
60
|
+
"no_refresh_token",
|
|
61
|
+
`provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return tokens.refreshToken;
|
|
65
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { InboundMailConnectErrors } from "./constants.ts";
|
|
4
|
+
|
|
5
|
+
const CONNECT_CONFLICT_MESSAGES: Readonly<Record<string, string>> = {
|
|
6
|
+
[InboundMailConnectErrors.addressAlreadyConnected.reason]:
|
|
7
|
+
"this mailbox address is already connected",
|
|
8
|
+
[InboundMailConnectErrors.addressDisconnected.reason]:
|
|
9
|
+
"this mailbox was disconnected before; reconnect it from the mailbox list instead",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function connectFailureResponse(c: Context, error: unknown) {
|
|
13
|
+
const reason = propertyOf(propertyOf(error, "details"), "reason");
|
|
14
|
+
const conflict = typeof reason === "string" ? CONNECT_CONFLICT_MESSAGES[reason] : undefined;
|
|
15
|
+
if (typeof reason === "string" && conflict) return errorJson(c, 409, reason, conflict);
|
|
16
|
+
return errorJson(c, 502, "account_create_failed", JSON.stringify(error ?? {}));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function errorJson(
|
|
20
|
+
c: Context,
|
|
21
|
+
status: 400 | 401 | 403 | 404 | 409 | 500 | 502,
|
|
22
|
+
code: string,
|
|
23
|
+
message: string,
|
|
24
|
+
) {
|
|
25
|
+
return c.json({ error: { code, message } }, status);
|
|
26
|
+
}
|
|
@@ -13,30 +13,81 @@
|
|
|
13
13
|
import type {
|
|
14
14
|
ExtraRouteDefinition,
|
|
15
15
|
SignatureExtraRouteDeps,
|
|
16
|
-
SignatureExtraRouteVerifyRequest,
|
|
17
16
|
UserExtraRouteDeps,
|
|
18
17
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
-
import {
|
|
18
|
+
import { signatureRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
20
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
21
20
|
import type { Context } from "hono";
|
|
22
21
|
import {
|
|
22
|
+
refreshTokenOrRefusal,
|
|
23
|
+
type VerifiedCallback,
|
|
24
|
+
verifyCallbackRequest,
|
|
25
|
+
} from "./connect-callback-verify.ts";
|
|
26
|
+
import { connectFailureResponse, errorJson } from "./connect-route-responses.ts";
|
|
27
|
+
import {
|
|
28
|
+
INBOUND_MAIL_CONNECT_PATH,
|
|
23
29
|
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
24
30
|
InboundMailAuthMethods,
|
|
25
31
|
InboundMailFoundationHandlers,
|
|
26
|
-
inboundCredentialSecretKey,
|
|
27
32
|
} from "./constants.ts";
|
|
33
|
+
import { isMailAdmin } from "./handlers/scope-visibility.ts";
|
|
34
|
+
import { readInboundMailRoles } from "./mail-roles.ts";
|
|
28
35
|
import {
|
|
29
|
-
inboundRefreshTokenSecretOptions,
|
|
30
36
|
type OAuthAccessTokenManager,
|
|
37
|
+
storeOAuthCallbackCredential,
|
|
31
38
|
} from "./oauth-access-token.ts";
|
|
32
|
-
import {
|
|
39
|
+
import { signOAuthState } from "./oauth-state.ts";
|
|
33
40
|
import { resolveInboundProviderForKey } from "./provider-factory.ts";
|
|
34
41
|
import type { InboundMailContext } from "./types.ts";
|
|
35
42
|
|
|
36
43
|
const DEFAULT_STATE_TTL_MINUTES = 15;
|
|
37
|
-
const DEFAULT_CONNECT_PATH = "/api/inbound-mail/connect";
|
|
38
44
|
const DEFAULT_CALLBACK_PATH = "/inbound-mail/oauth/callback";
|
|
39
45
|
|
|
46
|
+
// connect-account leaves a reopened mailbox off: switched on before its token is stored, the
|
|
47
|
+
// supervisor would sync it with the old, failing credential.
|
|
48
|
+
async function reactivateIfReopened(
|
|
49
|
+
c: Context,
|
|
50
|
+
deps: SignatureExtraRouteDeps,
|
|
51
|
+
tenantId: string,
|
|
52
|
+
accountId: string,
|
|
53
|
+
connected: unknown,
|
|
54
|
+
): Promise<Response | undefined> {
|
|
55
|
+
if (propertyOf(connected, "reconnected") !== true) return undefined;
|
|
56
|
+
const reactivated = await deps.dispatchSystemWrite({
|
|
57
|
+
handlerQn: InboundMailFoundationHandlers.reactivateAccount,
|
|
58
|
+
tenantId,
|
|
59
|
+
payload: { accountId },
|
|
60
|
+
});
|
|
61
|
+
return reactivated.isSuccess ? undefined : connectFailureResponse(c, reactivated.error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The callback connects as a system user, so connect-account's role rules (mail member at all,
|
|
65
|
+
// admin for a shared mailbox) have to hold here, before the signed state is handed out.
|
|
66
|
+
function connectRoleRefusal(
|
|
67
|
+
c: Context,
|
|
68
|
+
deps: UserExtraRouteDeps,
|
|
69
|
+
scope: "user" | "shared",
|
|
70
|
+
): Response | undefined {
|
|
71
|
+
const { memberRoles, adminRoles } = readInboundMailRoles(deps.registry);
|
|
72
|
+
if (!memberRoles.some((role) => deps.user.roles.includes(role))) {
|
|
73
|
+
return errorJson(
|
|
74
|
+
c,
|
|
75
|
+
403,
|
|
76
|
+
"mail_role_required",
|
|
77
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: connecting a mailbox needs one of the roles ${memberRoles.join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (scope === "shared" && !isMailAdmin(deps.user, adminRoles)) {
|
|
81
|
+
return errorJson(
|
|
82
|
+
c,
|
|
83
|
+
403,
|
|
84
|
+
"shared_mailbox_requires_admin",
|
|
85
|
+
`${INBOUND_MAIL_FOUNDATION_FEATURE}: only ${adminRoles.join(", ")} connect a shared mailbox`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
40
91
|
export type InboundMailConnectRoutesOptions = {
|
|
41
92
|
/** HMAC-secret for the state param (deploy-time env — `scope:"system"`
|
|
42
93
|
* secrets don't exist in secrets-v1). */
|
|
@@ -62,36 +113,6 @@ export type InboundMailConnectRoutesOptions = {
|
|
|
62
113
|
readonly metricsFor?: InboundMailContext["metricsFor"];
|
|
63
114
|
};
|
|
64
115
|
|
|
65
|
-
function errorJson(c: Context, status: 400 | 401 | 404 | 500 | 502, code: string, message: string) {
|
|
66
|
-
return c.json({ error: { code, message } }, status);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
type VerifiedCallback = { readonly code: string; readonly state: OAuthStatePayload };
|
|
70
|
-
|
|
71
|
-
function verifyCallbackRequest(
|
|
72
|
-
request: SignatureExtraRouteVerifyRequest,
|
|
73
|
-
stateSecret: string,
|
|
74
|
-
): VerifiedCallback {
|
|
75
|
-
const code = request.query["code"] ?? "";
|
|
76
|
-
const rawState = request.query["state"] ?? "";
|
|
77
|
-
if (!code || !rawState) {
|
|
78
|
-
throw new ExtraRouteRejection(
|
|
79
|
-
400,
|
|
80
|
-
{ error: { code: "invalid_callback", message: "missing code or state" } },
|
|
81
|
-
"inbound-mail oauth callback missing code or state",
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
const verified = verifyOAuthState(rawState, stateSecret);
|
|
85
|
-
if (!verified.ok) {
|
|
86
|
-
throw new ExtraRouteRejection(
|
|
87
|
-
400,
|
|
88
|
-
{ error: { code: "invalid_state", message: `state rejected: ${verified.reason}` } },
|
|
89
|
-
`inbound-mail oauth callback state rejected: ${verified.reason}`,
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
return { code, state: verified.payload };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
116
|
/** Builds the `entry:"user"`/`entry:"signature"` extraRoute pair. Mount via
|
|
96
117
|
* `extraRoutes: createInboundMailConnectRoutes(options)`. */
|
|
97
118
|
export function createInboundMailConnectRoutes(
|
|
@@ -101,7 +122,7 @@ export function createInboundMailConnectRoutes(
|
|
|
101
122
|
|
|
102
123
|
const connectRoute: ExtraRouteDefinition = {
|
|
103
124
|
method: "GET",
|
|
104
|
-
path: options.connectPath ??
|
|
125
|
+
path: options.connectPath ?? INBOUND_MAIL_CONNECT_PATH,
|
|
105
126
|
entry: "user",
|
|
106
127
|
handler: async (c: Context, deps: UserExtraRouteDeps): Promise<Response> => {
|
|
107
128
|
const providerKey = c.req.query("provider") ?? "";
|
|
@@ -116,6 +137,9 @@ export function createInboundMailConnectRoutes(
|
|
|
116
137
|
);
|
|
117
138
|
}
|
|
118
139
|
|
|
140
|
+
const refused = connectRoleRefusal(c, deps, scope);
|
|
141
|
+
if (refused) return refused;
|
|
142
|
+
|
|
119
143
|
let plugin: ReturnType<typeof resolveInboundProviderForKey>;
|
|
120
144
|
try {
|
|
121
145
|
plugin = resolveInboundProviderForKey({ registry: deps.registry }, providerKey);
|
|
@@ -213,17 +237,8 @@ export function createInboundMailConnectRoutes(
|
|
|
213
237
|
"token exchange with the provider failed",
|
|
214
238
|
);
|
|
215
239
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// request offline_access (or access_type=offline) — the provider's
|
|
219
|
-
// oauth block owns the scopes.
|
|
220
|
-
return errorJson(
|
|
221
|
-
c,
|
|
222
|
-
502,
|
|
223
|
-
"no_refresh_token",
|
|
224
|
-
`provider "${state.providerKey}" returned no refresh token — check the offline-access scope in the provider's oauth.scopes`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
240
|
+
const refreshToken = refreshTokenOrRefusal(c, state, tokens);
|
|
241
|
+
if (typeof refreshToken !== "string") return refreshToken;
|
|
227
242
|
|
|
228
243
|
// Account creation (programmatic SystemUser; the real owner comes from
|
|
229
244
|
// the HMAC-verified state → ownerUserIdOverride).
|
|
@@ -240,30 +255,46 @@ export function createInboundMailConnectRoutes(
|
|
|
240
255
|
},
|
|
241
256
|
});
|
|
242
257
|
if (!dispatched.isSuccess) {
|
|
243
|
-
return
|
|
258
|
+
return connectFailureResponse(c, dispatched.error);
|
|
244
259
|
}
|
|
245
260
|
const accountId = propertyOf(dispatched.data, "accountId");
|
|
246
261
|
if (typeof accountId !== "string") {
|
|
247
262
|
return errorJson(c, 502, "account_create_failed", "connect-account returned no accountId");
|
|
248
263
|
}
|
|
249
264
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await options.oauthTokens.storeRefreshToken(
|
|
265
|
+
try {
|
|
266
|
+
await storeOAuthCallbackCredential(
|
|
267
|
+
options.oauthTokens,
|
|
268
|
+
deps.secrets,
|
|
255
269
|
{ id: accountId, tenantId: state.tenantId },
|
|
256
|
-
tokens
|
|
270
|
+
tokens,
|
|
271
|
+
refreshToken,
|
|
257
272
|
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
inboundRefreshTokenSecretOptions(accountId),
|
|
273
|
+
} catch {
|
|
274
|
+
return errorJson(
|
|
275
|
+
c,
|
|
276
|
+
502,
|
|
277
|
+
"credential_store_failed",
|
|
278
|
+
"the sign-in worked but its token could not be saved; the mailbox keeps its previous state, connect it again",
|
|
265
279
|
);
|
|
266
280
|
}
|
|
281
|
+
const refused = await reactivateIfReopened(
|
|
282
|
+
c,
|
|
283
|
+
deps,
|
|
284
|
+
state.tenantId,
|
|
285
|
+
accountId,
|
|
286
|
+
dispatched.data,
|
|
287
|
+
);
|
|
288
|
+
if (refused) return refused;
|
|
289
|
+
|
|
290
|
+
// Best effort: the account commit already woke the supervisor before the token existed.
|
|
291
|
+
// Without this event the first sync would wait for the next poll tick; a failure here
|
|
292
|
+
// only costs that delay, so the connect itself still succeeds.
|
|
293
|
+
await deps.dispatchSystemWrite({
|
|
294
|
+
handlerQn: InboundMailFoundationHandlers.markCredentialStored,
|
|
295
|
+
tenantId: state.tenantId,
|
|
296
|
+
payload: { accountId },
|
|
297
|
+
});
|
|
267
298
|
|
|
268
299
|
if (options.successRedirectUrl) {
|
|
269
300
|
const target = new URL(options.successRedirectUrl);
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { INBOUND_MAIL_FOUNDATION_FEATURE } from "./web/constants.ts";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
INBOUND_MAIL_CONNECT_PATH,
|
|
5
|
+
INBOUND_MAIL_FOUNDATION_FEATURE,
|
|
6
|
+
INBOUND_MAIL_RECONNECT_OAUTH_ACTION,
|
|
7
|
+
} from "./web/constants.ts";
|
|
3
8
|
|
|
4
9
|
// Extension-point name for provider plugins (inbound-provider-imap,
|
|
5
10
|
// inbound-provider-m365-graph, inbound-provider-gmail-rest, ...).
|
|
@@ -20,6 +25,18 @@ export const InboundMailFoundationHandlers = {
|
|
|
20
25
|
/** Tenant admin disconnects a mailbox. The stream remains for auditing;
|
|
21
26
|
* the status becomes disconnected and the watch stops. */
|
|
22
27
|
disconnectAccount: "inbound-mail-foundation:write:disconnect-account",
|
|
28
|
+
/** The only way from disconnected back to active; also clears auth_error
|
|
29
|
+
* and degraded. update-account keeps rejecting disconnected accounts. */
|
|
30
|
+
reactivateAccount: "inbound-mail-foundation:write:reactivate-account",
|
|
31
|
+
setTrustedAuthservId: "inbound-mail-foundation:write:set-trusted-authserv-id",
|
|
32
|
+
/** Owner or admin asks for one quarantined mail to be loaded again on the next sync. */
|
|
33
|
+
retryQuarantinedMessage: "inbound-mail-foundation:write:retry-quarantined-message",
|
|
34
|
+
/** Marks all open quarantine entries of a mailbox as acknowledged. */
|
|
35
|
+
acknowledgeQuarantine: "inbound-mail-foundation:write:acknowledge-quarantine",
|
|
36
|
+
/** Supervisor-only: stores the content of an already imported snippet mail. */
|
|
37
|
+
attachMessageContent: "inbound-mail-foundation:write:attach-message-content",
|
|
38
|
+
/** OAuth callback only: the refresh token is stored now, so an active account can sync. */
|
|
39
|
+
markCredentialStored: "inbound-mail-foundation:write:mark-credential-stored",
|
|
23
40
|
} as const;
|
|
24
41
|
|
|
25
42
|
// Qualified query handler names.
|
|
@@ -29,6 +46,10 @@ export const InboundMailFoundationQueries = {
|
|
|
29
46
|
/** Tenant-scoped message list (inbox cockpit). PII is decrypted on read.
|
|
30
47
|
*/
|
|
31
48
|
listMessages: "inbound-mail-foundation:query:message:list",
|
|
49
|
+
/** Open quarantine entries of the mailboxes the caller may manage, with sender and subject from the header import. */
|
|
50
|
+
listQuarantine: "inbound-mail-foundation:query:quarantine:list",
|
|
51
|
+
/** Decrypted full text, attachment ids and content state of one message. */
|
|
52
|
+
messageContent: "inbound-mail-foundation:query:message:content",
|
|
32
53
|
} as const;
|
|
33
54
|
|
|
34
55
|
// Normalized account status values — provider-agnostic.
|
|
@@ -37,7 +58,7 @@ export const InboundMailAccountStatuses = {
|
|
|
37
58
|
active: "active",
|
|
38
59
|
/** Credentials or token are invalid; the tenant admin must reconnect. */
|
|
39
60
|
authError: "auth_error",
|
|
40
|
-
/**
|
|
61
|
+
/** Interrupted: transient failure, quarantined mail, missing file storage or KMS; see lastError. */
|
|
41
62
|
degraded: "degraded",
|
|
42
63
|
/** Disconnected by the tenant admin; no further syncs run. */
|
|
43
64
|
disconnected: "disconnected",
|
|
@@ -45,6 +66,34 @@ export const InboundMailAccountStatuses = {
|
|
|
45
66
|
export type InboundMailAccountStatus =
|
|
46
67
|
(typeof InboundMailAccountStatuses)[keyof typeof InboundMailAccountStatuses];
|
|
47
68
|
|
|
69
|
+
// Why a mailbox is degraded. Codes only, never provider error text (that carries hosts/tokens).
|
|
70
|
+
export const InboundMailLastErrors = {
|
|
71
|
+
connectionFailed: "connection_failed",
|
|
72
|
+
rateLimited: "rate_limited",
|
|
73
|
+
messageQuarantined: "message_quarantined",
|
|
74
|
+
fileStorageUnavailable: "file_storage_unavailable",
|
|
75
|
+
encryptionUnavailable: "encryption_unavailable",
|
|
76
|
+
ingestUnavailable: "ingest_unavailable",
|
|
77
|
+
} as const;
|
|
78
|
+
export type InboundMailLastError =
|
|
79
|
+
(typeof InboundMailLastErrors)[keyof typeof InboundMailLastErrors];
|
|
80
|
+
|
|
81
|
+
// Business reasons of failed connect writes; connect-routes maps both to 409 with the reason code.
|
|
82
|
+
export const InboundMailConnectErrors = {
|
|
83
|
+
addressAlreadyConnected: {
|
|
84
|
+
reason: "address_already_connected",
|
|
85
|
+
i18nKey: "inboundMail.accounts.errors.addressAlreadyConnected",
|
|
86
|
+
},
|
|
87
|
+
addressDisconnected: {
|
|
88
|
+
reason: "address_disconnected",
|
|
89
|
+
i18nKey: "inboundMail.accounts.errors.addressDisconnected",
|
|
90
|
+
},
|
|
91
|
+
credentialRequired: {
|
|
92
|
+
reason: "credential_required",
|
|
93
|
+
i18nKey: "inboundMail.accounts.errors.credentialRequired",
|
|
94
|
+
},
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
48
97
|
/**
|
|
49
98
|
* Tenant secret slot for an account: IMAP app password OR OAuth refresh
|
|
50
99
|
* token. The provider interprets the contents according to `authMethod`.
|
|
@@ -71,6 +120,20 @@ export const InboundMailAuthMethods = {
|
|
|
71
120
|
export type InboundMailAuthMethod =
|
|
72
121
|
(typeof InboundMailAuthMethods)[keyof typeof InboundMailAuthMethods];
|
|
73
122
|
|
|
123
|
+
/** Provider key of inbound-provider-imap: only its password mailboxes have a credential form. */
|
|
124
|
+
export const IMAP_PROVIDER_KEY = "imap";
|
|
125
|
+
|
|
126
|
+
// How a mailbox the caller may re-arm gets its credential back, computed by account:list because a
|
|
127
|
+
// screen row condition cannot combine provider, auth method, status and owner.
|
|
128
|
+
export const MailAccountReactivationPaths = {
|
|
129
|
+
/** The IMAP form of inbound-provider-imap (reconnect-mailbox). */
|
|
130
|
+
passwordForm: "password_form",
|
|
131
|
+
/** The provider's OAuth sign-in through the connect route; its callback reopens the account. */
|
|
132
|
+
oauthFlow: "oauth_flow",
|
|
133
|
+
} as const;
|
|
134
|
+
export type MailAccountReactivationPath =
|
|
135
|
+
(typeof MailAccountReactivationPaths)[keyof typeof MailAccountReactivationPaths];
|
|
136
|
+
|
|
74
137
|
// **Multi-provider:** no global `provider` config key
|
|
75
138
|
// (unlike mail-foundation, as with billing-foundation). A tenant can connect
|
|
76
139
|
// an IMAP mailbox and an M365 mailbox in parallel; the provider is stored per
|