@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
|
@@ -3,109 +3,269 @@ import {
|
|
|
3
3
|
MAX_LIST_LIMIT,
|
|
4
4
|
type ProjectionListScreenDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
ActionFormScreenDefinition,
|
|
8
|
+
RowActionClientAction,
|
|
9
|
+
RowActionNavigate,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
7
11
|
import {
|
|
12
|
+
INBOUND_MAIL_RECONNECT_OAUTH_ACTION,
|
|
8
13
|
InboundMailAccountStatuses,
|
|
9
14
|
InboundMailFoundationHandlers,
|
|
10
15
|
InboundMailFoundationQueries,
|
|
16
|
+
MailAccountReactivationPaths,
|
|
11
17
|
} from "./constants.ts";
|
|
18
|
+
import type { InboundMailRoles } from "./mail-roles.ts";
|
|
12
19
|
|
|
13
20
|
export const MAIL_ACCOUNT_LIST_SCREEN_ID = "mail-accounts";
|
|
14
21
|
export const MAIL_ACCOUNT_EDIT_SCREEN_ID = "mail-account-edit";
|
|
15
22
|
export const MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:mail-accounts";
|
|
23
|
+
export const MY_MAIL_ACCOUNT_LIST_SCREEN_ID = "my-mail-accounts";
|
|
24
|
+
export const MY_MAIL_ACCOUNT_LIST_SCREEN_QN = "inbound-mail-foundation:screen:my-mail-accounts";
|
|
16
25
|
// Registered by inbound-provider-imap, which needs the IMAP client to check the login. The
|
|
17
26
|
// renderer drops a navigate action whose target screen is not mounted, so the toolbar button
|
|
18
27
|
// only shows in apps that mount that provider.
|
|
28
|
+
export const MAIL_ACCOUNT_SENDER_CHECK_SCREEN_ID = "mail-account-sender-check";
|
|
19
29
|
export const IMAP_CONNECT_SCREEN_ID = "mail-account-connect-imap";
|
|
30
|
+
// Same reason for the row action "Reactivate" of a password mailbox.
|
|
31
|
+
export const IMAP_RECONNECT_SCREEN_ID = "mail-account-reconnect-imap";
|
|
32
|
+
export const MY_IMAP_RECONNECT_SCREEN_ID = "my-mail-account-reconnect-imap";
|
|
33
|
+
export const MY_IMAP_CONNECT_SCREEN_ID = "my-mail-account-connect-imap";
|
|
34
|
+
export const MAIL_QUARANTINE_SCREEN_ID = "mail-quarantine";
|
|
20
35
|
|
|
21
36
|
export const MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX = "inboundMail.accounts.status.";
|
|
37
|
+
export const MAIL_ACCOUNT_LAST_ERROR_OPTION_KEY_PREFIX = "inboundMail.accounts.lastError.";
|
|
22
38
|
|
|
23
|
-
//
|
|
39
|
+
// Screens get the same roles as the handlers behind them: a screen rule wider than the
|
|
24
40
|
// handlers would only render actions that 403.
|
|
25
|
-
|
|
41
|
+
|
|
42
|
+
const reactivateOauthAction: RowActionClientAction = {
|
|
43
|
+
kind: "clientAction",
|
|
44
|
+
id: "reactivate-oauth",
|
|
45
|
+
label: i18nKey("inboundMail.accounts.reactivate"),
|
|
46
|
+
action: INBOUND_MAIL_RECONNECT_OAUTH_ACTION,
|
|
47
|
+
payload: { map: { provider: "provider", address: "address", ownerUserId: "ownerUserId" } },
|
|
48
|
+
confirm: i18nKey("inboundMail.accounts.reactivateOauth.confirm"),
|
|
49
|
+
visible: { field: "reactivateVia", eq: MailAccountReactivationPaths.oauthFlow },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const reactivateImapOwnerAction: RowActionNavigate = {
|
|
53
|
+
kind: "navigate",
|
|
54
|
+
id: "reactivate",
|
|
55
|
+
label: i18nKey("inboundMail.accounts.reactivate"),
|
|
56
|
+
screen: MY_IMAP_RECONNECT_SCREEN_ID,
|
|
57
|
+
entityId: "id",
|
|
58
|
+
visible: { field: "reactivateVia", eq: MailAccountReactivationPaths.passwordForm },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const quarantineAction: RowActionNavigate = {
|
|
62
|
+
kind: "navigate",
|
|
63
|
+
id: "quarantine",
|
|
64
|
+
label: i18nKey("inboundMail.accounts.quarantine"),
|
|
65
|
+
screen: MAIL_QUARANTINE_SCREEN_ID,
|
|
66
|
+
visible: { field: "hasOpenQuarantine", eq: true },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const nameAddressStatusColumns: ProjectionListScreenDefinition["columns"] = [
|
|
70
|
+
{ field: "displayName", label: i18nKey("inboundMail.accounts.col.name"), sortable: false },
|
|
71
|
+
{ field: "address", label: i18nKey("inboundMail.accounts.col.address"), sortable: false },
|
|
72
|
+
{
|
|
73
|
+
field: "status",
|
|
74
|
+
label: i18nKey("inboundMail.accounts.col.status"),
|
|
75
|
+
sortable: false,
|
|
76
|
+
renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_STATUS_OPTION_KEY_PREFIX },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
field: "lastError",
|
|
80
|
+
label: i18nKey("inboundMail.accounts.col.lastError"),
|
|
81
|
+
sortable: false,
|
|
82
|
+
renderer: { format: "enumOption", keyPrefix: MAIL_ACCOUNT_LAST_ERROR_OPTION_KEY_PREFIX },
|
|
83
|
+
},
|
|
84
|
+
];
|
|
26
85
|
|
|
27
86
|
// Several mailboxes per tenant, one row each. The bound query already filters
|
|
28
87
|
// personal mailboxes the caller neither owns nor administers and returns no
|
|
29
88
|
// credential: the secret lives in the secrets feature, never in the row.
|
|
30
89
|
// dormant: the consuming app places the list in its own settings nav.
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
export function createMailAccountListScreen({
|
|
91
|
+
adminRoles,
|
|
92
|
+
}: InboundMailRoles): ProjectionListScreenDefinition {
|
|
93
|
+
return {
|
|
94
|
+
id: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
95
|
+
type: "projectionList",
|
|
96
|
+
agent: { expose: false },
|
|
97
|
+
query: InboundMailFoundationQueries.listAccounts,
|
|
98
|
+
pagination: false,
|
|
99
|
+
pageSize: MAX_LIST_LIMIT,
|
|
100
|
+
columns: [
|
|
101
|
+
...nameAddressStatusColumns,
|
|
102
|
+
{
|
|
103
|
+
field: "provider",
|
|
104
|
+
label: i18nKey("inboundMail.accounts.col.provider"),
|
|
105
|
+
sortable: false,
|
|
106
|
+
hideOnNarrow: true,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
field: "quarantinedCount",
|
|
110
|
+
label: i18nKey("inboundMail.accounts.col.quarantined"),
|
|
111
|
+
sortable: false,
|
|
112
|
+
hideOnNarrow: true,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
field: "connectedAt",
|
|
116
|
+
label: i18nKey("inboundMail.accounts.col.connected"),
|
|
117
|
+
sortable: false,
|
|
118
|
+
hideOnNarrow: true,
|
|
119
|
+
renderer: { format: "timestamp" },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
rowActions: [
|
|
123
|
+
{
|
|
124
|
+
kind: "navigate",
|
|
125
|
+
id: "edit",
|
|
126
|
+
label: i18nKey("inboundMail.accounts.edit"),
|
|
127
|
+
screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
128
|
+
params: { map: { accountId: "id", displayName: "displayName" } },
|
|
129
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
kind: "navigate",
|
|
133
|
+
id: "sender-check",
|
|
134
|
+
label: i18nKey("inboundMail.accounts.senderCheck"),
|
|
135
|
+
screen: MAIL_ACCOUNT_SENDER_CHECK_SCREEN_ID,
|
|
136
|
+
params: { map: { accountId: "id", trustedAuthservId: "trustedAuthservId" } },
|
|
137
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
kind: "navigate",
|
|
141
|
+
id: "reactivate",
|
|
142
|
+
label: i18nKey("inboundMail.accounts.reactivate"),
|
|
143
|
+
screen: IMAP_RECONNECT_SCREEN_ID,
|
|
144
|
+
entityId: "id",
|
|
145
|
+
// Brings a disconnected or sign-in-failed mailbox back in place, keeping its history.
|
|
146
|
+
// reactivateVia comes from the list query: only the owner re-arms a personal mailbox.
|
|
147
|
+
visible: { field: "reactivateVia", eq: MailAccountReactivationPaths.passwordForm },
|
|
148
|
+
},
|
|
149
|
+
reactivateOauthAction,
|
|
150
|
+
quarantineAction,
|
|
151
|
+
{
|
|
152
|
+
kind: "writeHandler",
|
|
153
|
+
id: "acknowledge-quarantine",
|
|
154
|
+
label: i18nKey("inboundMail.accounts.acknowledgeQuarantine"),
|
|
155
|
+
handler: InboundMailFoundationHandlers.acknowledgeQuarantine,
|
|
156
|
+
payload: { map: { accountId: "id" } },
|
|
157
|
+
visible: { field: "hasOpenQuarantine", eq: true },
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
kind: "writeHandler",
|
|
161
|
+
id: "disconnect",
|
|
162
|
+
label: i18nKey("inboundMail.accounts.disconnect"),
|
|
163
|
+
handler: InboundMailFoundationHandlers.disconnectAccount,
|
|
164
|
+
payload: { map: { accountId: "id" } },
|
|
165
|
+
style: "danger",
|
|
166
|
+
confirm: i18nKey("inboundMail.accounts.disconnect.confirm"),
|
|
167
|
+
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
toolbarActions: [
|
|
171
|
+
{
|
|
172
|
+
kind: "navigate",
|
|
173
|
+
id: "connect-imap",
|
|
174
|
+
label: i18nKey("inboundMail.accounts.connectImap"),
|
|
175
|
+
screen: IMAP_CONNECT_SCREEN_ID,
|
|
176
|
+
style: "primary",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
access: { roles: [...adminRoles] },
|
|
180
|
+
dormant: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The mailboxes a user sees (own personal ones and the shared ones), for apps that let users connect
|
|
185
|
+
// their own mailbox: an owner re-arms a personal mailbox here without an admin.
|
|
186
|
+
// dormant: the consuming app places it next to its own connect flow.
|
|
187
|
+
export function createMyMailAccountListScreen({
|
|
188
|
+
memberRoles,
|
|
189
|
+
}: InboundMailRoles): ProjectionListScreenDefinition {
|
|
190
|
+
return {
|
|
191
|
+
id: MY_MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
192
|
+
type: "projectionList",
|
|
193
|
+
agent: { expose: false },
|
|
194
|
+
query: InboundMailFoundationQueries.listAccounts,
|
|
195
|
+
pagination: false,
|
|
196
|
+
pageSize: MAX_LIST_LIMIT,
|
|
197
|
+
columns: nameAddressStatusColumns,
|
|
198
|
+
rowActions: [reactivateImapOwnerAction, reactivateOauthAction, quarantineAction],
|
|
199
|
+
toolbarActions: [
|
|
200
|
+
{
|
|
201
|
+
kind: "navigate",
|
|
202
|
+
id: "connect-imap",
|
|
203
|
+
label: i18nKey("inboundMail.accounts.connectImap"),
|
|
204
|
+
screen: MY_IMAP_CONNECT_SCREEN_ID,
|
|
205
|
+
style: "primary",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
access: { roles: [...memberRoles] },
|
|
209
|
+
dormant: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createMailAccountEditScreen({
|
|
214
|
+
adminRoles,
|
|
215
|
+
}: InboundMailRoles): ActionFormScreenDefinition {
|
|
216
|
+
return {
|
|
217
|
+
id: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
218
|
+
type: "actionForm",
|
|
219
|
+
agent: { expose: false },
|
|
220
|
+
handler: InboundMailFoundationHandlers.updateAccount,
|
|
221
|
+
fields: {
|
|
222
|
+
accountId: { type: "text", required: true },
|
|
223
|
+
displayName: { type: "text", required: true, maxLength: 200 },
|
|
59
224
|
},
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
{
|
|
63
|
-
kind: "navigate",
|
|
64
|
-
id: "edit",
|
|
65
|
-
label: i18nKey("inboundMail.accounts.edit"),
|
|
66
|
-
screen: MAIL_ACCOUNT_EDIT_SCREEN_ID,
|
|
67
|
-
params: { map: { accountId: "id", displayName: "displayName" } },
|
|
68
|
-
visible: { field: "status", ne: InboundMailAccountStatuses.disconnected },
|
|
225
|
+
layout: {
|
|
226
|
+
sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
|
|
69
227
|
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
228
|
+
submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
|
|
229
|
+
redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
230
|
+
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
231
|
+
access: { roles: [...adminRoles] },
|
|
232
|
+
dormant: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createMailAccountSenderCheckScreen({
|
|
237
|
+
adminRoles,
|
|
238
|
+
}: InboundMailRoles): ActionFormScreenDefinition {
|
|
239
|
+
return {
|
|
240
|
+
id: MAIL_ACCOUNT_SENDER_CHECK_SCREEN_ID,
|
|
241
|
+
type: "actionForm",
|
|
242
|
+
agent: { expose: false },
|
|
243
|
+
handler: InboundMailFoundationHandlers.setTrustedAuthservId,
|
|
244
|
+
fields: {
|
|
245
|
+
accountId: { type: "text", required: true },
|
|
246
|
+
trustedAuthservId: { type: "text", maxLength: 255 },
|
|
79
247
|
},
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
style: "primary",
|
|
248
|
+
layout: {
|
|
249
|
+
sections: [
|
|
250
|
+
{
|
|
251
|
+
description: i18nKey("inboundMail.accounts.senderCheckHint"),
|
|
252
|
+
fields: [{ field: "accountId", readOnly: true }, "trustedAuthservId"],
|
|
253
|
+
},
|
|
254
|
+
],
|
|
88
255
|
},
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
256
|
+
submitLabel: i18nKey("inboundMail.accounts.senderCheck.submit"),
|
|
257
|
+
redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
258
|
+
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
259
|
+
access: { roles: [...adminRoles] },
|
|
260
|
+
dormant: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
93
263
|
|
|
94
|
-
export
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
},
|
|
103
|
-
layout: {
|
|
104
|
-
sections: [{ fields: [{ field: "accountId", readOnly: true }, "displayName"] }],
|
|
105
|
-
},
|
|
106
|
-
submitLabel: i18nKey("inboundMail.accounts.edit.submit"),
|
|
107
|
-
redirect: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
108
|
-
cancelTarget: MAIL_ACCOUNT_LIST_SCREEN_ID,
|
|
109
|
-
access: MAIL_ACCOUNT_ADMIN_ACCESS,
|
|
110
|
-
dormant: true,
|
|
111
|
-
};
|
|
264
|
+
export function createMailAccountScreens(roles: InboundMailRoles) {
|
|
265
|
+
return [
|
|
266
|
+
createMailAccountListScreen(roles),
|
|
267
|
+
createMyMailAccountListScreen(roles),
|
|
268
|
+
createMailAccountEditScreen(roles),
|
|
269
|
+
createMailAccountSenderCheckScreen(roles),
|
|
270
|
+
] as const;
|
|
271
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { type MailAccountRow, toMailAccountRecord } from "./account-record.ts";
|
|
15
15
|
import { InboundMailAccountStatuses } from "./constants.ts";
|
|
16
16
|
import { isVisibleToCaller } from "./handlers/scope-visibility.ts";
|
|
17
|
+
import { resolveOutboundAttachments } from "./outbound-attachments.ts";
|
|
17
18
|
import {
|
|
18
19
|
isMailSendNotConfiguredError,
|
|
19
20
|
isMailSendRejectedError,
|
|
@@ -22,13 +23,13 @@ import {
|
|
|
22
23
|
outboundMailMessageSchema,
|
|
23
24
|
} from "./outbound-mail.ts";
|
|
24
25
|
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
25
|
-
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
26
26
|
import {
|
|
27
|
-
type InboundMailContext,
|
|
28
27
|
isInboundAuthError,
|
|
29
28
|
isInboundRateLimitError,
|
|
30
29
|
isInboundTransientError,
|
|
31
|
-
} from "./
|
|
30
|
+
} from "./provider-errors.ts";
|
|
31
|
+
import { resolveInboundProviderForAccount } from "./provider-factory.ts";
|
|
32
|
+
import type { InboundMailContext } from "./types.ts";
|
|
32
33
|
|
|
33
34
|
export const MAIL_SEND_ERRORS = {
|
|
34
35
|
notConfigured: {
|
|
@@ -48,13 +49,23 @@ export const MAIL_SEND_ERRORS = {
|
|
|
48
49
|
reason: "mail_send_invalid_message",
|
|
49
50
|
i18nKey: "inboundMail.send.errors.invalidMessage",
|
|
50
51
|
},
|
|
52
|
+
attachmentInvalid: {
|
|
53
|
+
reason: "mail_send_attachment_invalid",
|
|
54
|
+
i18nKey: "inboundMail.send.errors.attachmentInvalid",
|
|
55
|
+
},
|
|
56
|
+
attachmentsTooLarge: {
|
|
57
|
+
reason: "mail_send_attachments_too_large",
|
|
58
|
+
i18nKey: "inboundMail.send.errors.attachmentsTooLarge",
|
|
59
|
+
},
|
|
51
60
|
} as const;
|
|
52
61
|
export type MailSendFailure = keyof typeof MAIL_SEND_ERRORS;
|
|
53
62
|
|
|
63
|
+
// Query-only dispatch: attachment reads run as the caller, and a send path must not be able to
|
|
64
|
+
// write through the caller's rights.
|
|
54
65
|
export type SendMailFromAccountContext = Pick<
|
|
55
66
|
HandlerContext,
|
|
56
67
|
"db" | "registry" | "user" | "services" | "obs"
|
|
57
|
-
|
|
68
|
+
> & { readonly dispatch: Pick<HandlerContext["dispatch"], "query"> };
|
|
58
69
|
|
|
59
70
|
export type SendMailFromAccountInput = {
|
|
60
71
|
readonly accountId: string;
|
|
@@ -104,8 +115,16 @@ export async function sendMailFromAccount(
|
|
|
104
115
|
const plugin = resolveInboundProviderForAccount(providerCtx, account, HANDLER_NAME);
|
|
105
116
|
if (!plugin.send) return sendFailure("notConfigured");
|
|
106
117
|
|
|
118
|
+
const attachmentFileIds = message.data.attachmentFileIds ?? [];
|
|
119
|
+
const resolved = await resolveOutboundAttachments(ctx, attachmentFileIds);
|
|
120
|
+
if (!resolved.isSuccess) return sendFailure(resolved.failure);
|
|
121
|
+
|
|
107
122
|
try {
|
|
108
|
-
|
|
123
|
+
const sent = await plugin.send(providerCtx, account, message.data, resolved.attachments);
|
|
124
|
+
return {
|
|
125
|
+
isSuccess: true,
|
|
126
|
+
data: { messageIdHeader: sent.messageIdHeader, attachmentFileIds: resolved.fileIds },
|
|
127
|
+
};
|
|
109
128
|
} catch (err) {
|
|
110
129
|
const failure = sendFailureOf(err);
|
|
111
130
|
if (!failure) throw err;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Sender authentication (SPF, DKIM, DMARC) as the receiving mail server reported it in its
|
|
2
|
+
// Authentication-Results header (RFC 8601). A wrong "pass" is a security bug, so every doubt
|
|
3
|
+
// ends in null (no statement) or "unknown".
|
|
4
|
+
//
|
|
5
|
+
// Trust (RFC 8601 sections 5 and 7.1): the account names the authserv-id of its own mail server
|
|
6
|
+
// (trustedAuthservId). Only the topmost Authentication-Results header is read: the receiving server
|
|
7
|
+
// prepends its own entry, so the topmost one is from the own server and everything below it can be
|
|
8
|
+
// forged by the sender. If the topmost header names a different authserv-id there is no result.
|
|
9
|
+
// Without a trusted id, without a matching header or when the header was evaluated for
|
|
10
|
+
// another From domain than the mail's, there is no result at all.
|
|
11
|
+
|
|
12
|
+
import * as z from "zod";
|
|
13
|
+
|
|
14
|
+
export const SENDER_AUTH_VERDICTS = ["pass", "fail", "none", "unknown"] as const;
|
|
15
|
+
export type SenderAuthVerdict = (typeof SENDER_AUTH_VERDICTS)[number];
|
|
16
|
+
|
|
17
|
+
const verdictSchema = z.enum(SENDER_AUTH_VERDICTS);
|
|
18
|
+
|
|
19
|
+
export const MAX_DKIM_DOMAINS = 10;
|
|
20
|
+
|
|
21
|
+
export const senderAuthenticationSchema = z.object({
|
|
22
|
+
/** The authserv-id of the trusted header the verdicts come from, lower case. */
|
|
23
|
+
authservId: z.string().min(1).max(255),
|
|
24
|
+
spf: verdictSchema,
|
|
25
|
+
dkim: verdictSchema,
|
|
26
|
+
/** header.d of the dkim results, lower case, at most MAX_DKIM_DOMAINS. */
|
|
27
|
+
dkimDomains: z.array(z.string().max(255)).max(MAX_DKIM_DOMAINS),
|
|
28
|
+
dmarc: verdictSchema,
|
|
29
|
+
/** Domain of the mail's From address, lower case: the domain the dmarc verdict was bound to. */
|
|
30
|
+
fromDomain: z.string().min(1).max(255),
|
|
31
|
+
});
|
|
32
|
+
export type SenderAuthentication = z.infer<typeof senderAuthenticationSchema>;
|
|
33
|
+
/** null = no trustworthy statement (no trusted id, no matching header, From domain mismatch). */
|
|
34
|
+
export const senderAuthenticationFieldSchema = senderAuthenticationSchema.nullable();
|
|
35
|
+
|
|
36
|
+
/** True only when the account's own server vouched for the From domain via DMARC. */
|
|
37
|
+
export function isSenderAuthenticated(
|
|
38
|
+
senderAuthentication: SenderAuthentication | null | undefined,
|
|
39
|
+
): boolean {
|
|
40
|
+
return senderAuthentication?.dmarc === "pass";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Empty input means "not set"; ids are compared case-insensitively, so they are stored lower case. */
|
|
44
|
+
export function normalizeTrustedAuthservId(raw: string | null | undefined): string | null {
|
|
45
|
+
const id = raw?.trim().toLowerCase() ?? "";
|
|
46
|
+
return id === "" ? null : id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function fromDomainOf(address: string): string | null {
|
|
50
|
+
const at = address.lastIndexOf("@");
|
|
51
|
+
const domain = (at < 0 ? "" : address.slice(at + 1)).trim().toLowerCase().replace(/>$/, "");
|
|
52
|
+
return domain === "" ? null : domain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RESULT_TO_VERDICT: Readonly<Record<string, SenderAuthVerdict>> = {
|
|
56
|
+
pass: "pass",
|
|
57
|
+
fail: "fail",
|
|
58
|
+
softfail: "fail",
|
|
59
|
+
none: "none",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Index right after the quoted string that starts at `start`; a backslash escapes the next char. */
|
|
63
|
+
function quotedStringEnd(value: string, start: number): number {
|
|
64
|
+
for (let i = start + 1; i < value.length; i++) {
|
|
65
|
+
const char = value.charAt(i);
|
|
66
|
+
if (char === "\\") i++;
|
|
67
|
+
else if (char === '"') return i + 1;
|
|
68
|
+
}
|
|
69
|
+
return value.length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Comments in parentheses may nest and may hide quoted text; folding is plain whitespace. */
|
|
73
|
+
function stripComments(value: string): string {
|
|
74
|
+
let depth = 0;
|
|
75
|
+
let out = "";
|
|
76
|
+
for (let i = 0; i < value.length; i++) {
|
|
77
|
+
const char = value.charAt(i);
|
|
78
|
+
if (depth === 0 && char === '"') {
|
|
79
|
+
const end = quotedStringEnd(value, i);
|
|
80
|
+
out += value.slice(i, end);
|
|
81
|
+
i = end - 1;
|
|
82
|
+
} else if (char === "\\" && depth > 0) {
|
|
83
|
+
i++;
|
|
84
|
+
} else if (char === "(") {
|
|
85
|
+
depth++;
|
|
86
|
+
} else if (char === ")" && depth > 0) {
|
|
87
|
+
depth--;
|
|
88
|
+
} else if (depth === 0) {
|
|
89
|
+
out += char;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitSegments(value: string): string[] {
|
|
96
|
+
const segments: string[] = [];
|
|
97
|
+
let current = "";
|
|
98
|
+
for (let i = 0; i < value.length; i++) {
|
|
99
|
+
const char = value.charAt(i);
|
|
100
|
+
if (char === '"') {
|
|
101
|
+
const end = quotedStringEnd(value, i);
|
|
102
|
+
current += value.slice(i, end);
|
|
103
|
+
i = end - 1;
|
|
104
|
+
} else if (char === ";") {
|
|
105
|
+
segments.push(current.trim());
|
|
106
|
+
current = "";
|
|
107
|
+
} else {
|
|
108
|
+
current += char;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
segments.push(current.trim());
|
|
112
|
+
return segments;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const RESULT_PATTERN = /^([a-z0-9-]+)\s*=\s*([a-z0-9-]+)(?:\s|$)/i;
|
|
116
|
+
const PROPERTY_PATTERN = /(?:^|\s)header\.(d|from)\s*=\s*"?([^\s";]+)"?/gi;
|
|
117
|
+
|
|
118
|
+
type MethodResult = {
|
|
119
|
+
readonly verdict: SenderAuthVerdict;
|
|
120
|
+
readonly headerD: string | null;
|
|
121
|
+
readonly headerFrom: string | null;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function propertyOf(segment: string, name: "d" | "from"): string | null {
|
|
125
|
+
for (const match of segment.matchAll(PROPERTY_PATTERN)) {
|
|
126
|
+
if (match[1]?.toLowerCase() === name) return (match[2] ?? "").toLowerCase();
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function methodResults(segments: readonly string[]): {
|
|
132
|
+
byMethod: Map<string, MethodResult[]>;
|
|
133
|
+
headerFroms: string[];
|
|
134
|
+
} {
|
|
135
|
+
const byMethod = new Map<string, MethodResult[]>();
|
|
136
|
+
const headerFroms: string[] = [];
|
|
137
|
+
for (const segment of segments) {
|
|
138
|
+
const match = RESULT_PATTERN.exec(segment);
|
|
139
|
+
if (!match) continue;
|
|
140
|
+
const method = (match[1] ?? "").toLowerCase();
|
|
141
|
+
const headerFrom = propertyOf(segment, "from");
|
|
142
|
+
if (headerFrom !== null) headerFroms.push(headerFrom);
|
|
143
|
+
const result: MethodResult = {
|
|
144
|
+
verdict: RESULT_TO_VERDICT[(match[2] ?? "").toLowerCase()] ?? "unknown",
|
|
145
|
+
headerD: propertyOf(segment, "d"),
|
|
146
|
+
headerFrom,
|
|
147
|
+
};
|
|
148
|
+
byMethod.set(method, [...(byMethod.get(method) ?? []), result]);
|
|
149
|
+
}
|
|
150
|
+
return { byMethod, headerFroms };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// DKIM: one valid signature is enough, a mail may carry several.
|
|
154
|
+
function combineAnyPass(results: readonly MethodResult[] | undefined): SenderAuthVerdict {
|
|
155
|
+
const verdicts = results?.map((result) => result.verdict) ?? [];
|
|
156
|
+
if (verdicts.includes("pass")) return "pass";
|
|
157
|
+
if (verdicts.includes("fail")) return "fail";
|
|
158
|
+
if (verdicts.includes("none")) return "none";
|
|
159
|
+
return "unknown";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// SPF and DMARC have one verdict per mail; contradicting entries must never read as a pass.
|
|
163
|
+
function combineAllPass(results: readonly MethodResult[] | undefined): SenderAuthVerdict {
|
|
164
|
+
const verdicts = results?.map((result) => result.verdict) ?? [];
|
|
165
|
+
if (verdicts.length > 0 && verdicts.every((verdict) => verdict === "pass")) return "pass";
|
|
166
|
+
if (verdicts.includes("fail")) return "fail";
|
|
167
|
+
if (verdicts.includes("none")) return "none";
|
|
168
|
+
return "unknown";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** header.from may carry a full address or a bare domain. */
|
|
172
|
+
function domainOfHeaderFrom(value: string): string {
|
|
173
|
+
return fromDomainOf(value) ?? value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param headerValues values of all Authentication-Results headers in file order, topmost first
|
|
178
|
+
* @param trustedAuthservId authserv-id of the account's own server; null = none set, no result
|
|
179
|
+
* @param fromDomain domain of the mail's From address
|
|
180
|
+
*/
|
|
181
|
+
export function parseSenderAuthentication(
|
|
182
|
+
headerValues: readonly string[],
|
|
183
|
+
trustedAuthservId: string | null,
|
|
184
|
+
fromDomain: string | null,
|
|
185
|
+
): SenderAuthentication | null {
|
|
186
|
+
const trusted = normalizeTrustedAuthservId(trustedAuthservId);
|
|
187
|
+
const from = fromDomain?.toLowerCase() ?? "";
|
|
188
|
+
if (trusted === null || from === "") return null;
|
|
189
|
+
const headerValue = headerValues[0];
|
|
190
|
+
if (headerValue === undefined) return null;
|
|
191
|
+
const [head = "", ...resultSegments] = splitSegments(stripComments(headerValue));
|
|
192
|
+
const authservId = head.split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
193
|
+
if (authservId !== trusted) return null;
|
|
194
|
+
const { byMethod, headerFroms } = methodResults(resultSegments);
|
|
195
|
+
if (headerFroms.some((value) => domainOfHeaderFrom(value) !== from)) return null;
|
|
196
|
+
const dmarcResults = byMethod.get("dmarc");
|
|
197
|
+
// A dmarc pass only means something for the domain it was evaluated for.
|
|
198
|
+
const dmarcBound = dmarcResults?.every((result) => result.headerFrom !== null) ?? false;
|
|
199
|
+
const dkimDomains = [
|
|
200
|
+
...new Set(
|
|
201
|
+
(byMethod.get("dkim") ?? []).flatMap((result) => (result.headerD ? [result.headerD] : [])),
|
|
202
|
+
),
|
|
203
|
+
].slice(0, MAX_DKIM_DOMAINS);
|
|
204
|
+
// Values come from sender-controlled headers: one that does not fit the stored shape (length)
|
|
205
|
+
// must give "no statement", not a validation failure that would quarantine the mail.
|
|
206
|
+
const parsed = senderAuthenticationSchema.safeParse({
|
|
207
|
+
authservId,
|
|
208
|
+
spf: combineAllPass(byMethod.get("spf")),
|
|
209
|
+
dkim: combineAnyPass(byMethod.get("dkim")),
|
|
210
|
+
dkimDomains,
|
|
211
|
+
dmarc: dmarcBound ? combineAllPass(dmarcResults) : "unknown",
|
|
212
|
+
fromDomain: from,
|
|
213
|
+
});
|
|
214
|
+
return parsed.success ? parsed.data : null;
|
|
215
|
+
}
|