diff --git a/.env.example b/.env.example
index 68476d1b8a..70d62ee2eb 100644
--- a/.env.example
+++ b/.env.example
@@ -276,3 +276,18 @@ NEXT_PRIVATE_PLAIN_API_KEY=
# DOS_INTERNAL_API_KEY=""
# NEXT_PRIVATE_DOS_INTERNAL_API_KEY=""
+# [[IN-HOUSE FEATURE FLAGS]]
+# Custom sending domains and the organisation SSO portal are implemented
+# in-house, so this installation gates them with its own flags instead of an
+# upstream licence claim. Both default to ENABLED; set to `false` to switch a
+# feature off instance-wide. The client-side flags
+# NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED / NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED
+# are derived from these in createPublicEnv() - do not set them directly.
+# OPTIONAL: Custom sending domains (DKIM). Also requires the NEXT_PRIVATE_SES_*
+# credentials; without them the API fails closed with NOT_SETUP rather than
+# creating a domain that could never send.
+# CROVE_FEATURE_EMAIL_DOMAINS="true"
+# OPTIONAL: Organisation single sign-on portal (any OpenID Connect provider).
+# Each organisation must still enable and configure its own portal.
+# CROVE_FEATURE_SSO_PORTAL="true"
+
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
index 8836d058ad..6e6bfdeef7 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
-import { IS_BILLING_ENABLED, IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
+import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
@@ -27,7 +27,6 @@ import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organ
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { SettingsHeader } from '~/components/general/settings-header';
-import { EmailDomainsUpsell } from '~/components/general/settings-upsell/email-domains-upsell';
import type { Route } from './+types/o.$orgUrl.settings.email-domains.$id';
@@ -100,6 +99,20 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
const pageHeader = t`Email Domain Settings`;
const pageSubtitle = t`Manage your email domain settings.`;
+ if (!IS_EMAIL_DOMAINS_ENABLED()) {
+ return (
+
diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
index d774d4a80d..c93db427a5 100644
--- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
+++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
@@ -1,5 +1,5 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
-import { IS_DOCUMENSO_CLOUD } from '@documenso/lib/constants/app';
+import { IS_DOCUMENSO_CLOUD, IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import {
@@ -65,7 +65,7 @@ export default function OrganisationSettingSSOLoginPage() {
const { t } = useLingui();
const organisation = useCurrentOrganisation();
- const isAuthenticationPortalEnabled = true;
+ const isAuthenticationPortalEnabled = IS_SSO_PORTAL_ENABLED();
const { data: authenticationPortal, isLoading: isLoadingAuthenticationPortal } =
trpc.enterprise.organisation.authenticationPortal.get.useQuery(
@@ -91,6 +91,27 @@ export default function OrganisationSettingSSOLoginPage() {
);
}
+ // Self-hosted installations that turned the feature off get a plain notice
+ // instead of an upsell: the query below is disabled, so without this branch
+ // the page would render its loading state forever.
+ if (!isAuthenticationPortalEnabled) {
+ return (
+
+
+
+
+
+ Single sign-on is disabled on this installation.
+
+
+
+ );
+ }
+
if (isLoadingAuthenticationPortal || !authenticationPortal) {
return
;
}
diff --git a/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx b/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
index cbf226b7c8..7f9f215c65 100644
--- a/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
+++ b/apps/remix/app/routes/_unauthenticated+/o.$orgUrl.signin.tsx
@@ -1,5 +1,6 @@
import { authClient } from '@documenso/auth/client';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
+import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Button } from '@documenso/ui/primitives/button';
@@ -68,10 +69,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
},
});
- if (
- !organisation ||
- !organisation.organisationAuthenticationPortal.enabled
- ) {
+ if (!IS_SSO_PORTAL_ENABLED() || !organisation || !organisation.organisationAuthenticationPortal.enabled) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
diff --git a/packages/auth/server/lib/utils/organisation-portal.ts b/packages/auth/server/lib/utils/organisation-portal.ts
index 9fb4d4c67b..6c6bccbc4b 100644
--- a/packages/auth/server/lib/utils/organisation-portal.ts
+++ b/packages/auth/server/lib/utils/organisation-portal.ts
@@ -1,4 +1,4 @@
-import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
@@ -18,6 +18,12 @@ type GetOrganisationAuthenticationPortalOptions =
export const getOrganisationAuthenticationPortalOptions = async (
options: GetOrganisationAuthenticationPortalOptions,
) => {
+ if (!IS_SSO_PORTAL_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'The organisation SSO portal is disabled on this installation',
+ });
+ }
+
const organisation = await prisma.organisation.findFirst({
where:
options.type === 'url'
diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts
index fb9e880a69..477c017e6b 100644
--- a/packages/lib/constants/app.ts
+++ b/packages/lib/constants/app.ts
@@ -53,6 +53,35 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () =>
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
+/**
+ * Custom sending domains are implemented in-house, so this installation gates
+ * them with its own flag instead of an upstream licence claim. Enabled unless
+ * the variable is explicitly `false`.
+ *
+ * Platform-aware like {@link IS_AI_FEATURES_CONFIGURED}: the server reads the
+ * private variable, the client reads the public flag derived from it in
+ * `createPublicEnv` so the navigation cannot advertise what the API will refuse.
+ */
+export const IS_EMAIL_DOMAINS_ENABLED = (): boolean => {
+ if (typeof window === 'undefined') {
+ return env('CROVE_FEATURE_EMAIL_DOMAINS') !== 'false';
+ }
+
+ return env('NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED') !== 'false';
+};
+
+/**
+ * The organisation SSO portal is implemented in-house and gated the same way as
+ * {@link IS_EMAIL_DOMAINS_ENABLED}.
+ */
+export const IS_SSO_PORTAL_ENABLED = (): boolean => {
+ if (typeof window === 'undefined') {
+ return env('CROVE_FEATURE_SSO_PORTAL') !== 'false';
+ }
+
+ return env('NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED') !== 'false';
+};
+
/**
* Whether this instance is Documenso Cloud (managed SaaS).
*
diff --git a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts
index 9acfc3d6a2..f80881414f 100644
--- a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts
+++ b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts
@@ -1,8 +1,8 @@
-// Use the lib (fork) implementations, not the EE originals: the fork's
-// getSesClient() returns null when SES is unconfigured and falls back to DNS
-// verification, while the EE versions throw - which made this job fail for
-// every pending domain on SES-less deployments while the manual "Verify"
-// button kept working.
+// Custom sending domains are an in-house feature and require Amazon SES: the
+// helpers below fail closed with NOT_SETUP when the NEXT_PRIVATE_SES_*
+// credentials are missing. On such an installation this job degrades to an
+// error count per pending domain (Promise.allSettled) instead of silently
+// reporting progress it never made.
import { reregisterEmailDomain } from '@documenso/lib/server-only/email-domain/reregister-email-domain';
import { verifyEmailDomain } from '@documenso/lib/server-only/email-domain/verify-email-domain';
import { prisma } from '@documenso/prisma';
diff --git a/packages/lib/server-only/email-domain/audit.ts b/packages/lib/server-only/email-domain/audit.ts
new file mode 100644
index 0000000000..739ba0316b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/audit.ts
@@ -0,0 +1,33 @@
+import type { EmailDomainStatus } from '@prisma/client';
+
+import { logger } from '../../utils/logger';
+import type { EmailDomainTransitionEvent } from './types';
+
+export type EmailDomainTransition = {
+ event: EmailDomainTransitionEvent;
+ emailDomainId: string;
+ organisationId: string;
+ domain: string;
+ previousStatus: EmailDomainStatus | null;
+ nextStatus: EmailDomainStatus | null;
+ reason: string;
+ /**
+ * Only populated for `takeover`, where two organisations are involved and the
+ * audit line has to be attributable to both.
+ */
+ takingOverOrganisationId?: string;
+};
+
+/**
+ * Emit the single structured audit line for a state transition.
+ *
+ * Key material is never part of a transition record: the DKIM private key and the
+ * ownership-challenge token are both secrets, and the selector/public key are
+ * already public in DNS so they add nothing to an investigation.
+ */
+export const logEmailDomainTransition = (transition: EmailDomainTransition): void => {
+ logger.info({
+ msg: 'email_domain_transition',
+ ...transition,
+ });
+};
diff --git a/packages/lib/server-only/email-domain/concurrency.ts b/packages/lib/server-only/email-domain/concurrency.ts
new file mode 100644
index 0000000000..7a2d21251b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/concurrency.ts
@@ -0,0 +1,62 @@
+import { MAX_CONCURRENT_EXTERNAL_OPERATIONS } from './constants';
+
+export type Semaphore = {
+ run:
(task: () => Promise) => Promise;
+};
+
+/**
+ * A counting semaphore over asynchronous work.
+ *
+ * Tasks must never acquire the semaphore recursively, otherwise the pool can
+ * deadlock waiting on a slot held by its own caller.
+ */
+export const createSemaphore = (limit: number): Semaphore => {
+ let activeCount = 0;
+ const waiters: Array<() => void> = [];
+
+ const release = () => {
+ const nextWaiter = waiters.shift();
+
+ // Handing the slot straight to a waiter keeps `activeCount` correct without
+ // a decrement/increment pair that another task could slip in between.
+ if (nextWaiter) {
+ nextWaiter();
+ return;
+ }
+
+ activeCount -= 1;
+ };
+
+ const acquire = async (): Promise => {
+ if (activeCount < limit) {
+ activeCount += 1;
+ return;
+ }
+
+ await new Promise((resolve) => {
+ waiters.push(resolve);
+ });
+ };
+
+ return {
+ run: async (task) => {
+ await acquire();
+
+ try {
+ return await task();
+ } finally {
+ release();
+ }
+ },
+ };
+};
+
+/**
+ * Shared ceiling for every outbound DNS and SES call.
+ *
+ * Verification is triggered both by an administrator pressing "Verify" — which
+ * fans out across every domain in an organisation at once — and by an hourly job.
+ * Without a process-wide bound a single click could open hundreds of concurrent
+ * sockets to resolvers and to SES.
+ */
+export const externalOperationSemaphore = createSemaphore(MAX_CONCURRENT_EXTERNAL_OPERATIONS);
diff --git a/packages/lib/server-only/email-domain/constant-time.ts b/packages/lib/server-only/email-domain/constant-time.ts
new file mode 100644
index 0000000000..cb1c81e6b9
--- /dev/null
+++ b/packages/lib/server-only/email-domain/constant-time.ts
@@ -0,0 +1,17 @@
+import { createHash, timingSafeEqual } from 'node:crypto';
+
+/**
+ * Compare two strings without leaking how much of them matched.
+ *
+ * `timingSafeEqual` refuses buffers of differing length, and the length itself is
+ * already a hint, so both sides are folded through SHA-256 first. That keeps the
+ * comparison constant-time for inputs of any length while still being an exact
+ * equality test — a digest collision is not reachable by an attacker who cannot
+ * read the expected value.
+ */
+export const isConstantTimeEqual = (left: string, right: string): boolean => {
+ const leftDigest = createHash('sha256').update(left, 'utf8').digest();
+ const rightDigest = createHash('sha256').update(right, 'utf8').digest();
+
+ return timingSafeEqual(leftDigest, rightDigest);
+};
diff --git a/packages/lib/server-only/email-domain/constants.ts b/packages/lib/server-only/email-domain/constants.ts
new file mode 100644
index 0000000000..9244a4eb1a
--- /dev/null
+++ b/packages/lib/server-only/email-domain/constants.ts
@@ -0,0 +1,94 @@
+/**
+ * Host (relative to the zone apex) of the TXT record that proves control of a
+ * domain.
+ *
+ * Deliberately independent of Amazon SES: DKIM and SPF records can be published
+ * by anyone who can reach a zone's DNS, so they cannot on their own bind a
+ * domain claim to the organisation that started it.
+ */
+export const OWNERSHIP_CHALLENGE_LABEL = '_crove-verify';
+
+export const OWNERSHIP_CHALLENGE_VALUE_PREFIX = 'crove-domain-verification=';
+
+/**
+ * Domain-separation prefix for the ownership-challenge HMAC. Bumping the version
+ * invalidates every outstanding challenge, which is the intended escape hatch if
+ * the derivation ever needs to change.
+ */
+export const OWNERSHIP_CHALLENGE_HMAC_CONTEXT = 'crove:email-domain-ownership-challenge:v1';
+
+export const DKIM_SELECTOR_PREFIX = 'crove-';
+
+export const DKIM_SELECTOR_RANDOM_LENGTH = 12;
+
+/**
+ * RFC 6376 fixes the parent of a DKIM public-key record to `_domainkey`.
+ *
+ * The stored `selector` column holds the record *host* (`._domainkey`)
+ * rather than the bare label so that it can be handed straight to a DNS
+ * provider. `packages/lib/utils/email-domains.ts` — and therefore every screen
+ * that shows an administrator their records — treats the first argument as a
+ * record name, and the SPF record it emits uses the same zone-relative
+ * convention (`@`).
+ */
+export const DKIM_SELECTOR_SUFFIX = '._domainkey';
+
+export const DKIM_MODULUS_LENGTH_BITS = 2048;
+
+export const MAX_DNS_LABEL_LENGTH = 63;
+
+export const PUNYCODE_LABEL_PREFIX = 'xn--';
+
+/**
+ * A PENDING claim older than this may be taken over by another organisation.
+ * 72h comfortably exceeds the 48h DNS propagation window we quote to
+ * administrators, so a claim inside the window may still be mid-setup.
+ */
+export const STALE_PENDING_CLAIM_TTL_MS = 72 * 60 * 60 * 1000;
+
+/**
+ * Challenge + DKIM + SPF, plus one SOA query used to corroborate an NXDOMAIN so
+ * that a broken resolver is never mistaken for a missing record.
+ */
+export const MAX_DNS_QUERIES_PER_VERIFICATION = 4;
+
+export const MAX_CONCURRENT_EXTERNAL_OPERATIONS = 8;
+
+/**
+ * Three consecutive authoritative negatives are required before an ACTIVE domain
+ * is demoted. A single negative is routinely produced by DNS provider outages,
+ * zone transfers and partial rollbacks, none of which say anything about whether
+ * the administrator still controls the domain.
+ */
+export const CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE = 3;
+
+export const EMAIL_DOMAIN_VERIFICATION_MAX_PER_HOUR = 100;
+
+export const SES_SPF_MECHANISMS: ReadonlySet = new Set(['include:amazonses.com', '+include:amazonses.com']);
+
+/**
+ * Domains that can never be a customer's own sending domain. Claiming one would
+ * let an organisation send mail that appears to come from a public mailbox
+ * provider.
+ */
+export const BLOCKED_SENDING_DOMAINS: ReadonlySet = new Set([
+ 'gmail.com',
+ 'googlemail.com',
+ 'yahoo.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'icloud.com',
+ 'me.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'aol.com',
+ 'zoho.com',
+ 'gmx.net',
+ 'gmx.com',
+ 'mail.ru',
+ 'yandex.ru',
+ 'yandex.com',
+ 'fastmail.com',
+]);
diff --git a/packages/lib/server-only/email-domain/create-email-domain.test.ts b/packages/lib/server-only/email-domain/create-email-domain.test.ts
new file mode 100644
index 0000000000..2bd19ba404
--- /dev/null
+++ b/packages/lib/server-only/email-domain/create-email-domain.test.ts
@@ -0,0 +1,305 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { ENCRYPTION_KEY } = vi.hoisted(() => {
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+ process.env.NEXT_PRIVATE_SES_ACCESS_KEY_ID = 'cleanroom-access-key';
+ process.env.NEXT_PRIVATE_SES_SECRET_ACCESS_KEY = 'cleanroom-secret-key';
+ process.env.NEXT_PRIVATE_SES_REGION = 'us-east-1';
+
+ return { ENCRYPTION_KEY: 'cleanroom-test-encryption-key' };
+});
+
+const mocks = vi.hoisted(() => ({
+ emailDomainFindUnique: vi.fn(),
+ emailDomainCreate: vi.fn(),
+ emailDomainUpdate: vi.fn(),
+ emailDomainDelete: vi.fn(),
+ rateLimitUpsert: vi.fn(),
+ sesSend: vi.fn(),
+ createEmailIdentityCommand: vi.fn(),
+ deleteEmailIdentityCommand: vi.fn(),
+ getEmailIdentityCommand: vi.fn(),
+ putDkimSigningAttributesCommand: vi.fn(),
+ resolveTxt: vi.fn(),
+ resolveSoa: vi.fn(),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ logError: vi.fn(),
+}));
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ emailDomain: {
+ findUnique: mocks.emailDomainFindUnique,
+ create: mocks.emailDomainCreate,
+ update: mocks.emailDomainUpdate,
+ delete: mocks.emailDomainDelete,
+ },
+ rateLimit: {
+ upsert: mocks.rateLimitUpsert,
+ },
+ },
+}));
+
+// The command classes are spied rather than stubbed with objects so that the
+// production `new SomeCommand(input)` keeps working and the inputs can be
+// asserted directly. `SESv2Client` is a class for the same reason: production
+// code constructs it, and an arrow function cannot be constructed.
+vi.mock('@aws-sdk/client-sesv2', () => ({
+ SESv2Client: vi.fn(
+ class {
+ send = mocks.sesSend;
+ },
+ ),
+ CreateEmailIdentityCommand: mocks.createEmailIdentityCommand,
+ DeleteEmailIdentityCommand: mocks.deleteEmailIdentityCommand,
+ GetEmailIdentityCommand: mocks.getEmailIdentityCommand,
+ PutEmailIdentityDkimSigningAttributesCommand: mocks.putDkimSigningAttributesCommand,
+}));
+
+vi.mock('node:dns/promises', () => ({
+ resolveTxt: mocks.resolveTxt,
+ resolveSoa: mocks.resolveSoa,
+}));
+
+vi.mock('../../utils/logger', () => ({
+ logger: {
+ info: mocks.logInfo,
+ warn: mocks.logWarn,
+ error: mocks.logError,
+ },
+}));
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { symmetricDecrypt } from '../../universal/crypto';
+import { STALE_PENDING_CLAIM_TTL_MS } from './constants';
+import { createEmailDomain } from './create-email-domain';
+
+const DKIM_VALUE_PREFIX = 'v=DKIM1; k=rsa; p=';
+const ORGANISATION_ID = 'org_cleanroom_create';
+const RIVAL_ORGANISATION_ID = 'org_rival';
+const CREATED_AT = new Date('2026-01-01T00:00:00.000Z');
+
+const readCreatePayload = (): Record => {
+ const call = mocks.emailDomainCreate.mock.calls.at(0);
+ const payload = call?.[0] as { data: Record } | undefined;
+
+ return payload?.data ?? {};
+};
+
+const buildExistingClaim = (overrides: Record = {}) => ({
+ id: 'email_domain_existing_claim',
+ domain: 'contested.example',
+ selector: 'crove-staleclaim01._domainkey',
+ status: 'PENDING',
+ createdAt: CREATED_AT,
+ organisationId: RIVAL_ORGANISATION_ID,
+ ...overrides,
+});
+
+describe('createEmailDomain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.emailDomainFindUnique.mockResolvedValue(null);
+ mocks.rateLimitUpsert.mockResolvedValue({ count: 1 });
+ mocks.sesSend.mockResolvedValue({});
+ mocks.emailDomainCreate.mockImplementation(async (args: { data: Record }) => ({
+ ...args.data,
+ createdAt: CREATED_AT,
+ updatedAt: CREATED_AT,
+ lastVerifiedAt: null,
+ emails: [],
+ }));
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('returns SPF, DKIM and ownership-challenge records and stores the private key encrypted', async () => {
+ const result = await createEmailDomain({ domain: 'example.com', organisationId: ORGANISATION_ID });
+
+ expect(result.records).toHaveLength(3);
+
+ const [dkimRecord, spfRecord, challengeRecord] = result.records;
+
+ expect(dkimRecord?.type).toBe('TXT');
+ expect(dkimRecord?.name).toMatch(/^crove-[a-z0-9]{12}\._domainkey$/);
+ expect(dkimRecord?.value.startsWith(DKIM_VALUE_PREFIX)).toBe(true);
+
+ expect(spfRecord?.type).toBe('TXT');
+ expect(spfRecord?.name).toBe('@');
+ expect(spfRecord?.value).toBe('v=spf1 include:amazonses.com -all');
+
+ expect(challengeRecord?.type).toBe('TXT');
+ expect(challengeRecord?.name).toBe('_crove-verify');
+ expect(challengeRecord?.value.startsWith('crove-domain-verification=')).toBe(true);
+
+ // The records dialog keys its list on `name`, so a collision would drop a record.
+ expect(new Set(result.records.map((record) => record.name)).size).toBe(3);
+
+ expect(result.emailDomain.status).toBe('PENDING');
+ expect(result.emailDomain.domain).toBe('example.com');
+ expect(result.emailDomain.organisationId).toBe(ORGANISATION_ID);
+ expect(result.emailDomain.emails).toEqual([]);
+ expect(result.emailDomain.selector).toBe(dkimRecord?.name);
+ expect(result.emailDomain.lastVerifiedAt).toBeNull();
+ expect('privateKey' in result.emailDomain).toBe(false);
+
+ const storedRow = readCreatePayload();
+
+ expect(storedRow.status).toBe('PENDING');
+ expect(String(storedRow.privateKey)).not.toContain('BEGIN PRIVATE KEY');
+
+ const decryptedPrivateKey = Buffer.from(
+ symmetricDecrypt({ key: ENCRYPTION_KEY, data: String(storedRow.privateKey) }),
+ ).toString('utf8');
+
+ expect(decryptedPrivateKey).toContain('BEGIN PRIVATE KEY');
+ expect(String(storedRow.publicKey)).toBe(dkimRecord?.value.slice(DKIM_VALUE_PREFIX.length));
+ });
+
+ it('registers the identity with Amazon SES using our own DKIM key', async () => {
+ const result = await createEmailDomain({ domain: 'example.com', organisationId: ORGANISATION_ID });
+
+ expect(mocks.createEmailIdentityCommand).toHaveBeenCalledWith({ EmailIdentity: 'example.com' });
+
+ const selectorLabel = result.emailDomain.selector.replace('._domainkey', '');
+
+ expect(mocks.putDkimSigningAttributesCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ EmailIdentity: 'example.com',
+ SigningAttributesOrigin: 'EXTERNAL',
+ SigningAttributes: expect.objectContaining({ DomainSigningSelector: selectorLabel }),
+ }),
+ );
+
+ expect(mocks.sesSend).toHaveBeenCalledTimes(2);
+ });
+
+ it('logs the creation as one structured audit transition', async () => {
+ await createEmailDomain({ domain: 'example.com', organisationId: ORGANISATION_ID });
+
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ msg: 'email_domain_transition',
+ event: 'created',
+ organisationId: ORGANISATION_ID,
+ domain: 'example.com',
+ previousStatus: null,
+ nextStatus: 'PENDING',
+ }),
+ );
+ });
+
+ it('throws NOT_SETUP and creates nothing when Amazon SES is unconfigured', async () => {
+ vi.stubEnv('NEXT_PRIVATE_SES_REGION', '');
+
+ await expect(createEmailDomain({ domain: 'example.com', organisationId: ORGANISATION_ID })).rejects.toMatchObject({
+ code: AppErrorCode.NOT_SETUP,
+ });
+
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ expect(mocks.sesSend).not.toHaveBeenCalled();
+ });
+
+ it('rejects a public mailbox provider domain', async () => {
+ await expect(createEmailDomain({ domain: 'gmail.com', organisationId: ORGANISATION_ID })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_BODY,
+ });
+
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ });
+
+ it('rejects single-label hosts and non-alphabetic TLDs', async () => {
+ await expect(createEmailDomain({ domain: 'intranet', organisationId: ORGANISATION_ID })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_BODY,
+ });
+
+ await expect(createEmailDomain({ domain: 'example.c0m', organisationId: ORGANISATION_ID })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_BODY,
+ });
+
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ });
+
+ it('refuses a domain another organisation holds ACTIVE', async () => {
+ mocks.emailDomainFindUnique.mockResolvedValue(buildExistingClaim({ status: 'ACTIVE' }));
+
+ await expect(
+ createEmailDomain({ domain: 'contested.example', organisationId: ORGANISATION_ID }),
+ ).rejects.toMatchObject({ code: AppErrorCode.ALREADY_EXISTS });
+
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ expect(mocks.emailDomainDelete).not.toHaveBeenCalled();
+ });
+
+ it('takes over a PENDING claim older than the takeover window', async () => {
+ const staleCreatedAt = new Date(Date.now() - STALE_PENDING_CLAIM_TTL_MS - 60 * 60 * 1000);
+
+ mocks.emailDomainFindUnique.mockResolvedValue(buildExistingClaim({ createdAt: staleCreatedAt }));
+
+ const result = await createEmailDomain({ domain: 'contested.example', organisationId: ORGANISATION_ID });
+
+ expect(result.emailDomain.organisationId).toBe(ORGANISATION_ID);
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: 'email_domain_existing_claim' } });
+ expect(mocks.emailDomainCreate).toHaveBeenCalledOnce();
+
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: 'takeover',
+ organisationId: RIVAL_ORGANISATION_ID,
+ takingOverOrganisationId: ORGANISATION_ID,
+ previousStatus: 'PENDING',
+ nextStatus: null,
+ }),
+ );
+ });
+
+ it('refuses a PENDING claim that is still inside the takeover window', async () => {
+ mocks.emailDomainFindUnique.mockResolvedValue(buildExistingClaim({ createdAt: new Date() }));
+
+ await expect(
+ createEmailDomain({ domain: 'contested.example', organisationId: ORGANISATION_ID }),
+ ).rejects.toMatchObject({ code: AppErrorCode.ALREADY_EXISTS });
+
+ expect(mocks.emailDomainDelete).not.toHaveBeenCalled();
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ });
+
+ it('never names the organisation holding a contested domain', async () => {
+ mocks.emailDomainFindUnique.mockResolvedValue(buildExistingClaim({ status: 'ACTIVE' }));
+
+ let caught: unknown;
+
+ try {
+ await createEmailDomain({ domain: 'contested.example', organisationId: ORGANISATION_ID });
+ } catch (error) {
+ caught = error;
+ }
+
+ const appError = AppError.parseError(caught);
+
+ expect(`${appError.message} ${appError.userMessage ?? ''}`).not.toContain(RIVAL_ORGANISATION_ID);
+ expect(JSON.stringify(appError)).not.toContain(RIVAL_ORGANISATION_ID);
+ });
+
+ it('rolls the row back when Amazon SES refuses the registration', async () => {
+ mocks.sesSend.mockRejectedValue(
+ Object.assign(new Error('LimitExceededException'), {
+ name: 'LimitExceededException',
+ $metadata: { httpStatusCode: 400, requestId: 'aws-request-id-1' },
+ }),
+ );
+
+ await expect(createEmailDomain({ domain: 'example.com', organisationId: ORGANISATION_ID })).rejects.toMatchObject({
+ code: AppErrorCode.UNKNOWN_ERROR,
+ });
+
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: expect.any(String) } });
+ expect(mocks.logError).toHaveBeenCalledWith(
+ expect.objectContaining({ msg: 'email_domain_ses_error', awsRequestId: 'aws-request-id-1' }),
+ );
+ });
+});
diff --git a/packages/lib/server-only/email-domain/create-email-domain.ts b/packages/lib/server-only/email-domain/create-email-domain.ts
index 7b45318068..0aef640566 100644
--- a/packages/lib/server-only/email-domain/create-email-domain.ts
+++ b/packages/lib/server-only/email-domain/create-email-domain.ts
@@ -1,154 +1,238 @@
-import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
-import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
-import { generateDatabaseId } from '@documenso/lib/universal/id';
-import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
-import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
+import type { EmailDomain, OrganisationEmail } from '@prisma/client';
import { EmailDomainStatus } from '@prisma/client';
-import { generateKeyPair } from 'node:crypto';
-import { promisify } from 'node:util';
-export const getSesClient = () => {
- const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
- const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
- const region = env('NEXT_PRIVATE_SES_REGION');
-
- if (!accessKeyId || !secretAccessKey || !region) {
- return null;
- }
-
- return new SESv2Client({
- region,
- credentials: {
- accessKeyId,
- secretAccessKey,
- },
- });
-};
-
-const flattenKey = (key: string) => {
- return key.trim().split('\n').slice(1, -1).join('');
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import type { TEmailDomain } from '../../types/email-domain';
+import { generateDatabaseId } from '../../universal/id';
+import { logger } from '../../utils/logger';
+import { logEmailDomainTransition } from './audit';
+import type { GeneratedDkimKeyPair } from './dkim-keys';
+import { generateDkimKeyPair } from './dkim-keys';
+import { buildEmailDomainDnsRecords } from './dns-records';
+import { resolveDomainClaim } from './domain-claim';
+import { assertDomainIsClaimable } from './domain-policy';
+import { assertEmailDomainEncryptionKey, encryptDkimPrivateKey } from './key-material';
+import { deriveOwnershipChallengeToken } from './ownership-challenge';
+import { isPrismaConflictOn } from './prisma-conflict';
+import { assertSesServiceConfigured } from './ses-client';
+import { registerSesEmailIdentity } from './ses-identity';
+import type { EmailDomainDnsRecord } from './types';
+
+/**
+ * A selector carries 62 bits of randomness, so a collision is not a realistic
+ * event — but the column is globally unique, so the insert has to cope with one
+ * rather than surfacing a database error to an administrator.
+ */
+const MAX_ROW_INSERT_ATTEMPTS = 2;
+
+type PersistedEmailDomain = EmailDomain & { emails: OrganisationEmail[] };
+
+type AllocatedEmailDomain = {
+ emailDomain: PersistedEmailDomain;
+ keyPair: GeneratedDkimKeyPair;
+ ownershipChallengeToken: string;
};
-export async function verifyDomainWithDKIM(domain: string, selector: string, privateKey: string) {
- const sesClient = getSesClient();
-
- if (!sesClient) {
- // If AWS SES credentials are not set, return without error
- return null;
- }
-
- const command = new CreateEmailIdentityCommand({
- EmailIdentity: domain,
- DkimSigningAttributes: {
- DomainSigningSelector: selector,
- DomainSigningPrivateKey: privateKey,
- },
- });
-
- return await sesClient.send(command);
-}
-
export type CreateEmailDomainOptions = {
+ /**
+ * Already lowercased and regex-validated by the tRPC layer; re-normalised and
+ * re-checked here for every other caller.
+ */
domain: string;
organisationId: string;
};
-export type DomainRecord = {
- name: string;
- value: string;
- type: string;
+export type CreateEmailDomainResult = {
+ emailDomain: TEmailDomain;
+ records: EmailDomainDnsRecord[];
};
-export const createEmailDomain = async ({ domain, organisationId }: CreateEmailDomainOptions) => {
- const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
+/**
+ * Project a row onto the public response contract.
+ *
+ * Done field by field rather than by spreading so that the encrypted DKIM private
+ * key can never ride along inside an object that happens to carry one.
+ */
+const toEmailDomainResponse = (emailDomain: PersistedEmailDomain): TEmailDomain => {
+ return {
+ id: emailDomain.id,
+ status: emailDomain.status,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ selector: emailDomain.selector,
+ publicKey: emailDomain.publicKey,
+ createdAt: emailDomain.createdAt,
+ updatedAt: emailDomain.updatedAt,
+ lastVerifiedAt: emailDomain.lastVerifiedAt,
+ emails: emailDomain.emails.map((email) => ({
+ id: email.id,
+ createdAt: email.createdAt,
+ updatedAt: email.updatedAt,
+ email: email.email,
+ emailName: email.emailName,
+ emailDomainId: email.emailDomainId,
+ organisationId: email.organisationId,
+ })),
+ };
+};
+
+const insertEmailDomainRow = async ({
+ emailDomainId,
+ organisationId,
+ domain,
+ keyPair,
+ encryptedPrivateKey,
+}: {
+ emailDomainId: string;
+ organisationId: string;
+ domain: string;
+ keyPair: GeneratedDkimKeyPair;
+ encryptedPrivateKey: string;
+}): Promise => {
+ try {
+ return await prisma.emailDomain.create({
+ data: {
+ id: emailDomainId,
+ status: EmailDomainStatus.PENDING,
+ organisationId,
+ domain,
+ selector: keyPair.selector,
+ publicKey: keyPair.publicKeyFlattened,
+ privateKey: encryptedPrivateKey,
+ },
+ include: { emails: true },
+ });
+ } catch (error) {
+ // Claiming the globally unique domain here is what closes the race between
+ // the pre-flight claim check and the insert.
+ if (isPrismaConflictOn(error, 'domain')) {
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, {
+ message: 'The domain was registered while this request was being processed.',
+ userMessage: 'This domain is already in use.',
+ });
+ }
+
+ if (isPrismaConflictOn(error, 'selector')) {
+ return null;
+ }
- if (!encryptionKey) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
+ throw error;
}
+};
- const cleanDomain = domain.toLowerCase().trim();
- const selector = `crove-${organisationId}`.replace(/[_.]/g, '-');
- const recordName = `${selector}._domainkey.${cleanDomain}`;
+const allocateEmailDomain = async ({
+ emailDomainId,
+ organisationId,
+ domain,
+ encryptionKey,
+}: {
+ emailDomainId: string;
+ organisationId: string;
+ domain: string;
+ encryptionKey: string;
+}): Promise => {
+ for (let attempt = 1; attempt <= MAX_ROW_INSERT_ATTEMPTS; attempt++) {
+ const keyPair = generateDkimKeyPair();
+ const ownershipChallengeToken = deriveOwnershipChallengeToken(
+ { emailDomainId, selector: keyPair.selector, domain },
+ encryptionKey,
+ );
+
+ const emailDomain = await insertEmailDomainRow({
+ emailDomainId,
+ organisationId,
+ domain,
+ keyPair,
+ encryptedPrivateKey: encryptDkimPrivateKey(keyPair.privateKeyPem),
+ });
- // Check if domain already exists in database
- const existingDomain = await prisma.emailDomain.findUnique({
- where: {
- domain: cleanDomain,
- },
- });
+ if (emailDomain) {
+ return { emailDomain, keyPair, ownershipChallengeToken };
+ }
+ }
+
+ return null;
+};
- if (existingDomain) {
- throw new AppError(AppErrorCode.ALREADY_EXISTS, {
- message: 'Domain already exists in database',
+const releaseFailedRegistration = async (emailDomainId: string): Promise => {
+ try {
+ await prisma.emailDomain.delete({
+ where: { id: emailDomainId },
+ });
+ } catch (rollbackError) {
+ logger.error({
+ msg: 'email_domain_registration_rollback_failed',
+ emailDomainId,
+ error: rollbackError,
});
}
+};
- // Generate 2048-bit RSA DKIM key pair
- const generateKeyPairAsync = promisify(generateKeyPair);
-
- const { publicKey, privateKey } = await generateKeyPairAsync('rsa', {
- modulusLength: 2048,
- publicKeyEncoding: {
- type: 'spki',
- format: 'pem',
- },
- privateKeyEncoding: {
- type: 'pkcs8',
- format: 'pem',
- },
+/**
+ * Register a domain an organisation may send mail from (F1, F2).
+ *
+ * The row is written before SES is called so that the globally unique domain is
+ * claimed atomically; if SES then refuses, the row is removed again, so a domain
+ * is never left behind that could not send.
+ */
+export const createEmailDomain = async ({
+ domain,
+ organisationId,
+}: CreateEmailDomainOptions): Promise => {
+ assertSesServiceConfigured();
+
+ const encryptionKey = assertEmailDomainEncryptionKey();
+ const normalisedDomain = assertDomainIsClaimable(domain);
+
+ await resolveDomainClaim({ domain: normalisedDomain, organisationId });
+
+ const emailDomainId = generateDatabaseId('email_domain');
+
+ const allocation = await allocateEmailDomain({
+ emailDomainId,
+ organisationId,
+ domain: normalisedDomain,
+ encryptionKey,
});
- const publicKeyFlattened = flattenKey(publicKey);
- const privateKeyFlattened = flattenKey(privateKey);
-
- // Generate DNS records for user to add to their DNS provider
- const records: DomainRecord[] = generateEmailDomainRecords(recordName, publicKeyFlattened);
+ if (!allocation) {
+ throw new AppError(AppErrorCode.RETRY_EXCEPTION, {
+ message: 'Could not allocate a unique DKIM selector for the email domain.',
+ userMessage: 'We could not set up this domain. Please try again.',
+ });
+ }
- const encryptedPrivateKey = symmetricEncrypt({
- key: encryptionKey,
- data: privateKeyFlattened,
- });
+ const { emailDomain, keyPair, ownershipChallengeToken } = allocation;
- // Verify domain with SES if configured
- await verifyDomainWithDKIM(cleanDomain, selector, privateKeyFlattened).catch((err) => {
- if (err.name === 'AlreadyExistsException') {
- throw new AppError(AppErrorCode.ALREADY_EXISTS, {
- message: 'Domain already exists in SES',
- });
- }
+ try {
+ await registerSesEmailIdentity({
+ domain: normalisedDomain,
+ selectorLabel: keyPair.selectorLabel,
+ privateKeyPem: keyPair.privateKeyPem,
+ });
+ } catch (error) {
+ await releaseFailedRegistration(emailDomainId);
- console.warn('[Email Domain] AWS SES identity creation warning:', err?.message || err);
- });
+ throw error;
+ }
- const emailDomain = await prisma.emailDomain.create({
- data: {
- id: generateDatabaseId('email_domain'),
- domain: cleanDomain,
- status: EmailDomainStatus.PENDING,
- organisationId,
- selector: recordName,
- publicKey: publicKeyFlattened,
- privateKey: encryptedPrivateKey,
- },
- select: {
- id: true,
- status: true,
- organisationId: true,
- domain: true,
- selector: true,
- publicKey: true,
- createdAt: true,
- updatedAt: true,
- lastVerifiedAt: true,
- emails: true,
- },
+ logEmailDomainTransition({
+ event: 'created',
+ emailDomainId: emailDomain.id,
+ organisationId,
+ domain: normalisedDomain,
+ previousStatus: null,
+ nextStatus: EmailDomainStatus.PENDING,
+ reason: 'Domain registered and awaiting DNS configuration',
});
return {
- emailDomain,
- records,
+ emailDomain: toEmailDomainResponse(emailDomain),
+ records: buildEmailDomainDnsRecords({
+ selector: keyPair.selector,
+ publicKeyFlattened: keyPair.publicKeyFlattened,
+ ownershipChallengeToken,
+ }),
};
};
diff --git a/packages/lib/server-only/email-domain/delete-email-domain.test.ts b/packages/lib/server-only/email-domain/delete-email-domain.test.ts
new file mode 100644
index 0000000000..d2bc5e1602
--- /dev/null
+++ b/packages/lib/server-only/email-domain/delete-email-domain.test.ts
@@ -0,0 +1,173 @@
+import { EmailDomainStatus } from '@prisma/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+ process.env.NEXT_PRIVATE_SES_ACCESS_KEY_ID = 'cleanroom-access-key';
+ process.env.NEXT_PRIVATE_SES_SECRET_ACCESS_KEY = 'cleanroom-secret-key';
+ process.env.NEXT_PRIVATE_SES_REGION = 'us-east-1';
+});
+
+const mocks = vi.hoisted(() => ({
+ emailDomainFindUnique: vi.fn(),
+ emailDomainCreate: vi.fn(),
+ emailDomainUpdate: vi.fn(),
+ emailDomainDelete: vi.fn(),
+ rateLimitUpsert: vi.fn(),
+ sesSend: vi.fn(),
+ createEmailIdentityCommand: vi.fn(),
+ deleteEmailIdentityCommand: vi.fn(),
+ getEmailIdentityCommand: vi.fn(),
+ putDkimSigningAttributesCommand: vi.fn(),
+ resolveTxt: vi.fn(),
+ resolveSoa: vi.fn(),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ logError: vi.fn(),
+}));
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ emailDomain: {
+ findUnique: mocks.emailDomainFindUnique,
+ create: mocks.emailDomainCreate,
+ update: mocks.emailDomainUpdate,
+ delete: mocks.emailDomainDelete,
+ },
+ rateLimit: {
+ upsert: mocks.rateLimitUpsert,
+ },
+ },
+}));
+
+// `SESv2Client` is a class because production code constructs it; an arrow
+// function cannot be constructed.
+vi.mock('@aws-sdk/client-sesv2', () => ({
+ SESv2Client: vi.fn(
+ class {
+ send = mocks.sesSend;
+ },
+ ),
+ CreateEmailIdentityCommand: mocks.createEmailIdentityCommand,
+ DeleteEmailIdentityCommand: mocks.deleteEmailIdentityCommand,
+ GetEmailIdentityCommand: mocks.getEmailIdentityCommand,
+ PutEmailIdentityDkimSigningAttributesCommand: mocks.putDkimSigningAttributesCommand,
+}));
+
+vi.mock('node:dns/promises', () => ({
+ resolveTxt: mocks.resolveTxt,
+ resolveSoa: mocks.resolveSoa,
+}));
+
+vi.mock('../../utils/logger', () => ({
+ logger: {
+ info: mocks.logInfo,
+ warn: mocks.logWarn,
+ error: mocks.logError,
+ },
+}));
+
+import { AppErrorCode } from '../../errors/app-error';
+import { deleteEmailDomain } from './delete-email-domain';
+
+const EMAIL_DOMAIN_ID = 'email_domain_delete_target';
+const DOMAIN = 'example.com';
+const ORGANISATION_ID = 'org_cleanroom_delete';
+
+const buildRow = (overrides: Record = {}) => ({
+ id: EMAIL_DOMAIN_ID,
+ domain: DOMAIN,
+ selector: 'crove-deletetarget._domainkey',
+ status: EmailDomainStatus.ACTIVE,
+ organisationId: ORGANISATION_ID,
+ publicKey: 'unused-public-key',
+ privateKey: 'unused-encrypted-private-key',
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+ lastVerifiedAt: new Date('2026-02-01T00:00:00.000Z'),
+ ...overrides,
+});
+
+const sesError = (name: string, httpStatusCode?: number): Error =>
+ Object.assign(new Error(name), {
+ name,
+ $metadata: httpStatusCode === undefined ? undefined : { httpStatusCode, requestId: 'aws-request-id' },
+ });
+
+describe('deleteEmailDomain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.emailDomainFindUnique.mockResolvedValue(buildRow());
+ mocks.emailDomainDelete.mockResolvedValue(buildRow());
+ mocks.sesSend.mockResolvedValue({});
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('removes the Amazon SES identity and then the row', async () => {
+ await deleteEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.deleteEmailIdentityCommand).toHaveBeenCalledWith({ EmailIdentity: DOMAIN });
+ expect(mocks.sesSend).toHaveBeenCalledOnce();
+
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: EMAIL_DOMAIN_ID } });
+
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ msg: 'email_domain_transition',
+ event: 'deleted',
+ organisationId: ORGANISATION_ID,
+ domain: DOMAIN,
+ previousStatus: EmailDomainStatus.ACTIVE,
+ nextStatus: null,
+ }),
+ );
+ });
+
+ it('still removes the row when Amazon SES cannot be reached', async () => {
+ mocks.sesSend.mockRejectedValue(Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }));
+
+ await expect(deleteEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID })).resolves.toBeUndefined();
+
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: EMAIL_DOMAIN_ID } });
+ expect(mocks.logError).toHaveBeenCalledWith(
+ expect.objectContaining({
+ msg: 'email_domain_ses_orphan',
+ emailDomainId: EMAIL_DOMAIN_ID,
+ organisationId: ORGANISATION_ID,
+ domain: DOMAIN,
+ }),
+ );
+ });
+
+ it('does not treat an identity Amazon SES never had as an orphan', async () => {
+ mocks.sesSend.mockRejectedValue(sesError('NotFoundException', 404));
+
+ await deleteEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: EMAIL_DOMAIN_ID } });
+ expect(mocks.logError).not.toHaveBeenCalledWith(expect.objectContaining({ msg: 'email_domain_ses_orphan' }));
+ });
+
+ it('deletes the row even when Amazon SES is not configured at all', async () => {
+ vi.stubEnv('NEXT_PRIVATE_SES_REGION', '');
+
+ await deleteEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.sesSend).not.toHaveBeenCalled();
+ expect(mocks.emailDomainDelete).toHaveBeenCalledWith({ where: { id: EMAIL_DOMAIN_ID } });
+ });
+
+ it('throws NOT_FOUND for an unknown id', async () => {
+ mocks.emailDomainFindUnique.mockResolvedValue(null);
+
+ await expect(deleteEmailDomain({ emailDomainId: 'email_domain_missing' })).rejects.toMatchObject({
+ code: AppErrorCode.NOT_FOUND,
+ });
+
+ expect(mocks.emailDomainDelete).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/server-only/email-domain/delete-email-domain.ts b/packages/lib/server-only/email-domain/delete-email-domain.ts
index cee64867cf..ab39adc4df 100644
--- a/packages/lib/server-only/email-domain/delete-email-domain.ts
+++ b/packages/lib/server-only/email-domain/delete-email-domain.ts
@@ -1,20 +1,31 @@
-import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
-import { getSesClient } from './create-email-domain';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { logEmailDomainTransition } from './audit';
+import { logOrphanedSesIdentity, removeSesEmailIdentity } from './ses-identity';
+import { buildNegativeStreakKey, clearNegativeStreak } from './verification-state';
export type DeleteEmailDomainOptions = {
emailDomainId: string;
};
/**
- * Delete the email domain and SES email identity if applicable.
+ * Remove an email domain and its SES identity (F4).
+ *
+ * The SES call is best-effort and always runs first. An unreachable provider must
+ * not strand a row the administrator asked us to delete — they would keep seeing a
+ * domain they can no longer control — so the identity is logged as an orphan
+ * instead and the row goes regardless.
*/
-export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions) => {
+export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOptions): Promise => {
const emailDomain = await prisma.emailDomain.findUnique({
- where: {
- id: emailDomainId,
+ where: { id: emailDomainId },
+ select: {
+ id: true,
+ domain: true,
+ selector: true,
+ status: true,
+ organisationId: true,
},
});
@@ -24,25 +35,30 @@ export const deleteEmailDomain = async ({ emailDomainId }: DeleteEmailDomainOpti
});
}
- const sesClient = getSesClient();
-
- if (sesClient) {
- await sesClient
- .send(
- new DeleteEmailIdentityCommand({
- EmailIdentity: emailDomain.domain,
- }),
- )
- .catch((err) => {
- if (err.name !== 'NotFoundException') {
- console.warn('[Email Domain] Failed to delete SES identity:', err?.message || err);
- }
- });
+ const removal = await removeSesEmailIdentity({ domain: emailDomain.domain });
+
+ if (removal.kind === 'failed') {
+ logOrphanedSesIdentity({
+ domain: emailDomain.domain,
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ reason: removal.reason,
+ });
}
await prisma.emailDomain.delete({
- where: {
- id: emailDomainId,
- },
+ where: { id: emailDomain.id },
+ });
+
+ clearNegativeStreak(buildNegativeStreakKey({ emailDomainId: emailDomain.id, selector: emailDomain.selector }));
+
+ logEmailDomainTransition({
+ event: 'deleted',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ previousStatus: emailDomain.status,
+ nextStatus: null,
+ reason: removal.kind === 'failed' ? `Deleted with an orphaned SES identity (${removal.reason})` : 'Deleted',
});
};
diff --git a/packages/lib/server-only/email-domain/dkim-keys.ts b/packages/lib/server-only/email-domain/dkim-keys.ts
new file mode 100644
index 0000000000..4de1505bf2
--- /dev/null
+++ b/packages/lib/server-only/email-domain/dkim-keys.ts
@@ -0,0 +1,72 @@
+import { createPublicKey, generateKeyPairSync } from 'node:crypto';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { alphaid } from '../../universal/id';
+import {
+ DKIM_MODULUS_LENGTH_BITS,
+ DKIM_SELECTOR_PREFIX,
+ DKIM_SELECTOR_RANDOM_LENGTH,
+ DKIM_SELECTOR_SUFFIX,
+} from './constants';
+import { isDnsLegalLabel } from './domain-policy';
+
+export type GeneratedDkimKeyPair = {
+ /**
+ * The bare selector handed to Amazon SES, which publishes and looks for
+ * `._domainkey.` itself.
+ */
+ selectorLabel: string;
+ /**
+ * The zone-relative host of the DKIM TXT record. This is what is stored in the
+ * `selector` column.
+ */
+ selector: string;
+ /**
+ * Base64 of the DER-encoded SubjectPublicKeyInfo, on one line, ready for the
+ * `p=` tag.
+ */
+ publicKeyFlattened: string;
+ privateKeyPem: string;
+};
+
+export const buildDkimSelectorLabel = (): string => {
+ const label = `${DKIM_SELECTOR_PREFIX}${alphaid(DKIM_SELECTOR_RANDOM_LENGTH)}`;
+
+ if (!isDnsLegalLabel(label)) {
+ throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
+ message: 'Generated a DKIM selector label that is not DNS-legal.',
+ });
+ }
+
+ return label;
+};
+
+export const buildDkimSelector = (selectorLabel: string): string => {
+ return `${selectorLabel}${DKIM_SELECTOR_SUFFIX}`;
+};
+
+/**
+ * Generate the RSA key pair used for BYODKIM.
+ *
+ * We hold the key rather than letting SES manage it so that the public half we
+ * publish is the public half we can later prove is in DNS — SES-managed DKIM
+ * rotates keys on its own schedule and exposes only CNAME delegation records,
+ * which prove nothing about the key actually signing our mail.
+ */
+export const generateDkimKeyPair = (): GeneratedDkimKeyPair => {
+ const { publicKey, privateKey } = generateKeyPairSync('rsa', {
+ modulusLength: DKIM_MODULUS_LENGTH_BITS,
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+ });
+
+ const publicKeyDer: Buffer = createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
+ const selectorLabel = buildDkimSelectorLabel();
+
+ return {
+ selectorLabel,
+ selector: buildDkimSelector(selectorLabel),
+ publicKeyFlattened: publicKeyDer.toString('base64'),
+ privateKeyPem: privateKey,
+ };
+};
diff --git a/packages/lib/server-only/email-domain/dkim-record.test.ts b/packages/lib/server-only/email-domain/dkim-record.test.ts
new file mode 100644
index 0000000000..125376de7b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/dkim-record.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+
+import { evaluateDkimProof, normaliseDkimPublicKey, parseDkimTxtRecord } from './dkim-record';
+
+const PUBLIC_KEY = `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA${'A'.repeat(200)}`;
+const OTHER_PUBLIC_KEY = `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA${'B'.repeat(200)}`;
+
+const dkimRecord = (publicKey: string, extraTags = '') => [`v=DKIM1; ${extraTags}k=rsa; p=${publicKey}`];
+
+describe('parseDkimTxtRecord', () => {
+ it('reads the standard tag set', () => {
+ const parsed = parseDkimTxtRecord(dkimRecord(PUBLIC_KEY));
+
+ expect(parsed).not.toBeNull();
+ expect(parsed?.version).toBe('DKIM1');
+ expect(parsed?.keyType).toBe('rsa');
+ expect(parsed?.publicKey).toBe(PUBLIC_KEY);
+ expect(parsed?.disqualifyingReason).toBeNull();
+ });
+
+ it('concatenates the character-strings of one record before parsing', () => {
+ const parsed = parseDkimTxtRecord([`v=DKIM1; k=rsa; p=${PUBLIC_KEY.slice(0, 100)}`, PUBLIC_KEY.slice(100)]);
+
+ expect(parsed?.publicKey).toBe(PUBLIC_KEY);
+ });
+
+ it('tolerates folding whitespace and line breaks inside the key', () => {
+ const folded = PUBLIC_KEY.replace(/(.{60})/g, '$1 ');
+ const parsed = parseDkimTxtRecord([`v=DKIM1; k=rsa; p=${folded}`]);
+
+ expect(parsed?.publicKey).toBe(PUBLIC_KEY);
+ });
+
+ it('tolerates quoted tag values', () => {
+ const parsed = parseDkimTxtRecord([`v="DKIM1"; k="rsa"; p="${PUBLIC_KEY}"`]);
+
+ expect(parsed?.publicKey).toBe(PUBLIC_KEY);
+ expect(parsed?.disqualifyingReason).toBeNull();
+ });
+
+ it('defaults the key type to rsa when the tag is omitted', () => {
+ const parsed = parseDkimTxtRecord([`v=DKIM1; p=${PUBLIC_KEY}`]);
+
+ expect(parsed?.keyType).toBe('rsa');
+ });
+
+ it('returns null for an answer that is not a DKIM record', () => {
+ expect(parseDkimTxtRecord(['v=spf1 include:amazonses.com -all'])).toBeNull();
+ expect(parseDkimTxtRecord(['crove-domain-verification=abc'])).toBeNull();
+ });
+
+ it('returns null for a malformed tag list', () => {
+ expect(parseDkimTxtRecord(['v=DKIM1; nonsense; p=abc'])).toBeNull();
+ expect(parseDkimTxtRecord(['v=DKIM1; =rsa; p=abc'])).toBeNull();
+ });
+
+ it('returns null when a tag is repeated', () => {
+ expect(parseDkimTxtRecord([`v=DKIM1; k=rsa; k=rsa; p=${PUBLIC_KEY}`])).toBeNull();
+ });
+
+ it('returns null when the version tag is not first', () => {
+ expect(parseDkimTxtRecord([`k=rsa; v=DKIM1; p=${PUBLIC_KEY}`])).toBeNull();
+ });
+
+ it('disqualifies an unknown version', () => {
+ const parsed = parseDkimTxtRecord([`v=DKIM2; k=rsa; p=${PUBLIC_KEY}`]);
+
+ expect(parsed?.disqualifyingReason).not.toBeNull();
+ });
+
+ it('disqualifies a non-RSA key type', () => {
+ const parsed = parseDkimTxtRecord([`v=DKIM1; k=ed25519; p=${PUBLIC_KEY}`]);
+
+ expect(parsed?.disqualifyingReason).toContain('ed25519');
+ });
+
+ it('disqualifies a record published in testing mode', () => {
+ expect(parseDkimTxtRecord([`v=DKIM1; t=y; p=${PUBLIC_KEY}`])?.disqualifyingReason).toContain('testing mode');
+ expect(parseDkimTxtRecord([`v=DKIM1; t=s; p=${PUBLIC_KEY}`])?.disqualifyingReason).toBeNull();
+ });
+});
+
+describe('normaliseDkimPublicKey', () => {
+ it('strips folding whitespace and nothing else', () => {
+ expect(normaliseDkimPublicKey(' abc\tde\nf ')).toBe('abcdef');
+ expect(normaliseDkimPublicKey('aBc')).toBe('aBc');
+ });
+});
+
+describe('evaluateDkimProof', () => {
+ it('proves ownership when the whole key matches', () => {
+ expect(evaluateDkimProof([dkimRecord(PUBLIC_KEY)], PUBLIC_KEY).isProven).toBe(true);
+ });
+
+ it('proves ownership when the key is published with folding whitespace', () => {
+ const folded = PUBLIC_KEY.replace(/(.{60})/g, '$1\n');
+
+ expect(evaluateDkimProof([dkimRecord(folded)], PUBLIC_KEY).isProven).toBe(true);
+ });
+
+ it('refuses a record carrying somebody else key', () => {
+ expect(evaluateDkimProof([dkimRecord(OTHER_PUBLIC_KEY)], PUBLIC_KEY).isProven).toBe(false);
+ });
+
+ it('refuses a record whose key extends ours', () => {
+ expect(evaluateDkimProof([dkimRecord(`${PUBLIC_KEY}SUFFIX`)], PUBLIC_KEY).isProven).toBe(false);
+ });
+
+ it('refuses a record whose key is a prefix of ours', () => {
+ expect(evaluateDkimProof([dkimRecord(PUBLIC_KEY.slice(0, 80))], PUBLIC_KEY).isProven).toBe(false);
+ });
+
+ it('refuses a DKIM-shaped record with an empty key', () => {
+ const result = evaluateDkimProof([['v=DKIM1; k=rsa; p=']], PUBLIC_KEY);
+
+ expect(result.isProven).toBe(false);
+
+ if (result.isProven === false) {
+ expect(result.reason.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('refuses when only unrelated records are published at the selector', () => {
+ const result = evaluateDkimProof([['v=spf1 include:amazonses.com -all'], ['hello']], PUBLIC_KEY);
+
+ expect(result.isProven).toBe(false);
+ });
+
+ it('ignores unrelated records published alongside the real one', () => {
+ const records = [['v=spf1 include:amazonses.com -all'], dkimRecord(PUBLIC_KEY)];
+
+ expect(evaluateDkimProof(records, PUBLIC_KEY).isProven).toBe(true);
+ });
+
+ it('refuses when the matching record is disqualified', () => {
+ expect(evaluateDkimProof([dkimRecord(PUBLIC_KEY, 't=y; ')], PUBLIC_KEY).isProven).toBe(false);
+ });
+});
diff --git a/packages/lib/server-only/email-domain/dkim-record.ts b/packages/lib/server-only/email-domain/dkim-record.ts
new file mode 100644
index 0000000000..d6eb69ff1b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/dkim-record.ts
@@ -0,0 +1,176 @@
+import { isConstantTimeEqual } from './constant-time';
+import { flattenTxtRecord } from './dns';
+
+const DKIM_VERSION = 'DKIM1';
+const DKIM_TAG_NAME_PATTERN = /^[a-z0-9]+$/;
+const SUPPORTED_DKIM_KEY_TYPE = 'rsa';
+const DKIM_TESTING_FLAG = 'y';
+const WHITESPACE_PATTERN = /\s+/g;
+
+export type ParsedDkimRecord = {
+ version: string | null;
+ keyType: string;
+ flags: string | null;
+ /**
+ * Base64 of the public key with all folding whitespace removed.
+ */
+ publicKey: string;
+ /**
+ * Set when the record parses as DKIM but cannot prove anything — a bad version,
+ * an unsupported key algorithm, a duplicate tag, or testing mode.
+ */
+ disqualifyingReason: string | null;
+};
+
+/**
+ * Base64 is case-sensitive and carries no internal whitespace of its own, so
+ * stripping folding whitespace is the only normalisation that is safe before an
+ * exact comparison.
+ */
+export const normaliseDkimPublicKey = (value: string): string => {
+ return value.replace(WHITESPACE_PATTERN, '');
+};
+
+const unquoteTagValue = (value: string): string => {
+ const trimmed = value.trim();
+
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
+ return trimmed.slice(1, -1);
+ }
+
+ return trimmed;
+};
+
+const describeDkimDisqualification = (
+ version: string | null,
+ keyType: string | null,
+ flags: string | null,
+): string | null => {
+ if (version !== null && version.toUpperCase() !== DKIM_VERSION) {
+ return `Unsupported DKIM version "${version}"`;
+ }
+
+ if (keyType !== null && keyType.toLowerCase() !== SUPPORTED_DKIM_KEY_TYPE) {
+ return `Unsupported DKIM key type "${keyType}"`;
+ }
+
+ // t=y marks the domain as testing DKIM; RFC 6376 §3.6.1 tells verifiers not to
+ // treat such a domain as fully valid, so it cannot be accepted as proof.
+ const hasTestingFlag = (flags ?? '').split(':').some((flag) => flag.trim().toLowerCase() === DKIM_TESTING_FLAG);
+
+ if (hasTestingFlag) {
+ return 'DKIM record is published in testing mode (t=y)';
+ }
+
+ return null;
+};
+
+/**
+ * Parse one TXT answer as an RFC 6376 §3.6.1 DKIM key record.
+ *
+ * Returns null when the answer is not a DKIM record at all (no `p=` tag, or a
+ * syntactically broken tag list), so that unrelated TXT records published at the
+ * same name are simply skipped rather than treated as a failed proof.
+ */
+export const parseDkimTxtRecord = (chunks: string[]): ParsedDkimRecord | null => {
+ const segments = flattenTxtRecord(chunks).split(';');
+ const tags = new Map();
+ let firstTagName: string | null = null;
+
+ for (const segment of segments) {
+ const trimmedSegment = segment.trim();
+
+ // A trailing semicolon is legal and produces one empty segment.
+ if (trimmedSegment.length === 0) {
+ continue;
+ }
+
+ const separatorIndex = trimmedSegment.indexOf('=');
+
+ if (separatorIndex <= 0) {
+ return null;
+ }
+
+ const tagName = trimmedSegment.slice(0, separatorIndex).trim().toLowerCase();
+
+ if (!DKIM_TAG_NAME_PATTERN.test(tagName)) {
+ return null;
+ }
+
+ // RFC 6376 §3.2 forbids a tag appearing more than once.
+ if (tags.has(tagName)) {
+ return null;
+ }
+
+ if (firstTagName === null) {
+ firstTagName = tagName;
+ }
+
+ tags.set(tagName, unquoteTagValue(trimmedSegment.slice(separatorIndex + 1)));
+ }
+
+ const publicKey = tags.get('p');
+
+ if (publicKey === undefined) {
+ return null;
+ }
+
+ const version = tags.get('v') ?? null;
+
+ if (version !== null && firstTagName !== 'v') {
+ return null;
+ }
+
+ const disqualifyingReason = describeDkimDisqualification(version, tags.get('k') ?? null, tags.get('t') ?? null);
+
+ return {
+ version,
+ keyType: (tags.get('k') ?? SUPPORTED_DKIM_KEY_TYPE).toLowerCase(),
+ flags: tags.get('t') ?? null,
+ publicKey: normaliseDkimPublicKey(publicKey),
+ disqualifyingReason,
+ };
+};
+
+export type DkimProofResult = { isProven: true } | { isProven: false; reason: string };
+
+/**
+ * Decide whether DNS proves that the domain publishes *our* DKIM key.
+ *
+ * A record that merely looks like DKIM proves nothing — anyone able to reach a
+ * zone's DNS can publish `v=DKIM1; k=rsa; p=`. The whole base64
+ * key is compared, in constant time, against the key we generated; no prefix,
+ * substring or "contains" test is involved at any point.
+ */
+export const evaluateDkimProof = (records: string[][], expectedPublicKey: string): DkimProofResult => {
+ const expectedKey = normaliseDkimPublicKey(expectedPublicKey);
+ const disqualifyingReasons: string[] = [];
+ let didSeeDkimShapedRecord = false;
+
+ for (const chunks of records) {
+ const parsed = parseDkimTxtRecord(chunks);
+
+ if (parsed === null) {
+ continue;
+ }
+
+ didSeeDkimShapedRecord = true;
+
+ if (parsed.disqualifyingReason !== null) {
+ disqualifyingReasons.push(parsed.disqualifyingReason);
+ continue;
+ }
+
+ if (isConstantTimeEqual(parsed.publicKey, expectedKey)) {
+ return { isProven: true };
+ }
+
+ disqualifyingReasons.push('A DKIM record is published but its key is not ours');
+ }
+
+ if (!didSeeDkimShapedRecord) {
+ return { isProven: false, reason: 'No DKIM record is published for this selector' };
+ }
+
+ return { isProven: false, reason: disqualifyingReasons.at(0) ?? 'The published DKIM record does not match' };
+};
diff --git a/packages/lib/server-only/email-domain/dns-records.ts b/packages/lib/server-only/email-domain/dns-records.ts
new file mode 100644
index 0000000000..9689ec3f1a
--- /dev/null
+++ b/packages/lib/server-only/email-domain/dns-records.ts
@@ -0,0 +1,37 @@
+import { AWS_SES_SPF_RECORD, generateDkimRecord } from '../../utils/email-domains';
+import { buildOwnershipChallengeRecord } from './ownership-challenge';
+import type { EmailDomainDnsRecord } from './types';
+
+export type BuildEmailDomainDnsRecordsOptions = {
+ selector: string;
+ publicKeyFlattened: string;
+ ownershipChallengeToken: string;
+};
+
+/**
+ * The records an administrator has to publish, in the same order and shape the
+ * domain detail page renders from `generateEmailDomainRecords`, with the
+ * ownership challenge appended.
+ *
+ * Names are zone-relative (`@`, `_crove-verify`, `._domainkey`) because
+ * that is what DNS control panels ask for and what the shared helper already
+ * emits for SPF.
+ */
+export const buildEmailDomainDnsRecords = ({
+ selector,
+ publicKeyFlattened,
+ ownershipChallengeToken,
+}: BuildEmailDomainDnsRecordsOptions): EmailDomainDnsRecord[] => {
+ return [
+ generateDkimRecord(selector, publicKeyFlattened),
+ { ...AWS_SES_SPF_RECORD },
+ buildOwnershipChallengeRecord(ownershipChallengeToken),
+ ];
+};
+
+/**
+ * Fully-qualified name of the DKIM TXT record for a stored selector.
+ */
+export const dkimRecordHostName = (selector: string, domain: string): string => {
+ return `${selector}.${domain}`;
+};
diff --git a/packages/lib/server-only/email-domain/dns.ts b/packages/lib/server-only/email-domain/dns.ts
new file mode 100644
index 0000000000..827d972790
--- /dev/null
+++ b/packages/lib/server-only/email-domain/dns.ts
@@ -0,0 +1,113 @@
+import { resolveSoa, resolveTxt } from 'node:dns/promises';
+import { z } from 'zod';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { externalOperationSemaphore } from './concurrency';
+import { MAX_DNS_QUERIES_PER_VERIFICATION } from './constants';
+
+/**
+ * c-ares reports "the authoritative server says this name/type does not exist"
+ * with these codes.
+ *
+ * Everything else — SERVFAIL, timeouts, refused queries, malformed responses,
+ * socket errors — means we never got an answer, which says nothing about whether
+ * the record is published.
+ */
+const AUTHORITATIVE_NEGATIVE_DNS_ERROR_CODES: ReadonlySet = new Set(['ENOTFOUND', 'ENODATA']);
+
+const ZDnsErrorSchema = z.object({
+ code: z.string().optional(),
+ message: z.string().optional(),
+});
+
+export type DnsReadOutcome =
+ | { kind: 'answered'; value: TValue }
+ | { kind: 'absent'; code: string }
+ | { kind: 'unavailable'; code: string; detail: string };
+
+export type DnsQueryBudget = {
+ remaining: number;
+};
+
+export const createDnsQueryBudget = (): DnsQueryBudget => ({
+ remaining: MAX_DNS_QUERIES_PER_VERIFICATION,
+});
+
+const describeDnsError = (error: unknown): { code: string; detail: string } => {
+ const parsed = ZDnsErrorSchema.safeParse(error);
+
+ if (!parsed.success) {
+ return { code: 'UNKNOWN', detail: 'Unrecognised DNS failure' };
+ }
+
+ return {
+ code: parsed.data.code ?? 'UNKNOWN',
+ detail: parsed.data.message ?? 'DNS resolution failed',
+ };
+};
+
+const consumeDnsQuery = (budget: DnsQueryBudget): void => {
+ if (budget.remaining <= 0) {
+ throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
+ message: `Refusing to issue more than ${MAX_DNS_QUERIES_PER_VERIFICATION} DNS queries for one verification.`,
+ });
+ }
+
+ budget.remaining -= 1;
+};
+
+/**
+ * Re-join the chunks of a single TXT answer.
+ *
+ * RFC 1035 caps one character-string at 255 octets, so a long DKIM key is
+ * published as several strings inside one record and resolvers hand them back as
+ * an array. Concatenating them is the only way to read the value as published.
+ */
+export const flattenTxtRecord = (chunks: string[]): string => {
+ return chunks.join('');
+};
+
+export const readTxtRecords = async (name: string, budget: DnsQueryBudget): Promise> => {
+ consumeDnsQuery(budget);
+
+ try {
+ const value = await externalOperationSemaphore.run(() => resolveTxt(name));
+
+ return { kind: 'answered', value };
+ } catch (error) {
+ const { code, detail } = describeDnsError(error);
+
+ if (AUTHORITATIVE_NEGATIVE_DNS_ERROR_CODES.has(code)) {
+ return { kind: 'absent', code };
+ }
+
+ return { kind: 'unavailable', code, detail };
+ }
+};
+
+export type StartOfAuthorityState = 'resolved' | 'absent' | 'unavailable';
+
+/**
+ * Probe the zone apex so that an NXDOMAIN for a required name can be told apart
+ * from a resolver that cannot answer anything at all.
+ */
+export const readStartOfAuthorityState = async (
+ name: string,
+ budget: DnsQueryBudget,
+): Promise => {
+ consumeDnsQuery(budget);
+
+ try {
+ await externalOperationSemaphore.run(() => resolveSoa(name));
+
+ return 'resolved';
+ } catch (error) {
+ const { code } = describeDnsError(error);
+
+ if (AUTHORITATIVE_NEGATIVE_DNS_ERROR_CODES.has(code)) {
+ return 'absent';
+ }
+
+ return 'unavailable';
+ }
+};
diff --git a/packages/lib/server-only/email-domain/domain-claim.ts b/packages/lib/server-only/email-domain/domain-claim.ts
new file mode 100644
index 0000000000..9a8c65e33b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/domain-claim.ts
@@ -0,0 +1,101 @@
+import { prisma } from '@documenso/prisma';
+import { EmailDomainStatus } from '@prisma/client';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { logEmailDomainTransition } from './audit';
+import { STALE_PENDING_CLAIM_TTL_MS } from './constants';
+import { removeSesEmailIdentity } from './ses-identity';
+import { buildNegativeStreakKey, clearNegativeStreak } from './verification-state';
+
+const MS_PER_HOUR = 60 * 60 * 1000;
+
+export type ResolveDomainClaimOptions = {
+ domain: string;
+ organisationId: string;
+ /**
+ * Injectable so the takeover window can be exercised without waiting for it.
+ */
+ now?: Date;
+};
+
+/**
+ * Enforce the global uniqueness of `domain` and release claims that were started
+ * and abandoned.
+ *
+ * `selector` and `domain` are unique across the whole installation, not per
+ * organisation, so a second organisation asking for a domain someone else already
+ * holds has to be refused. The refusal never names the holder: which organisation
+ * owns a domain is not the requester's business, and the answer would otherwise
+ * be an oracle for enumerating customers.
+ *
+ * A PENDING claim is only released once it is older than the window we tell
+ * administrators DNS propagation can take, so a setup genuinely in progress is
+ * never stolen out from under its owner.
+ */
+export const resolveDomainClaim = async ({
+ domain,
+ organisationId,
+ now = new Date(),
+}: ResolveDomainClaimOptions): Promise => {
+ const existingClaim = await prisma.emailDomain.findUnique({
+ where: { domain },
+ select: {
+ id: true,
+ domain: true,
+ selector: true,
+ status: true,
+ createdAt: true,
+ organisationId: true,
+ },
+ });
+
+ if (!existingClaim) {
+ return;
+ }
+
+ if (existingClaim.organisationId === organisationId) {
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, {
+ message: 'This organisation has already registered the domain.',
+ userMessage: 'Your organisation has already added this domain.',
+ });
+ }
+
+ if (existingClaim.status === EmailDomainStatus.ACTIVE) {
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, {
+ message: 'The domain is verified and in use by another organisation.',
+ userMessage: 'This domain is already in use.',
+ });
+ }
+
+ const claimAgeMs = now.getTime() - existingClaim.createdAt.getTime();
+
+ if (claimAgeMs < STALE_PENDING_CLAIM_TTL_MS) {
+ const retryAfterHours = Math.max(1, Math.ceil((STALE_PENDING_CLAIM_TTL_MS - claimAgeMs) / MS_PER_HOUR));
+
+ throw new AppError(AppErrorCode.ALREADY_EXISTS, {
+ message: 'The domain has an unfinished registration held by another organisation.',
+ userMessage: `This domain is still being set up. Please try again in about ${retryAfterHours} hours.`,
+ });
+ }
+
+ await removeSesEmailIdentity({ domain: existingClaim.domain });
+
+ await prisma.emailDomain.delete({
+ where: { id: existingClaim.id },
+ });
+
+ clearNegativeStreak(buildNegativeStreakKey({ emailDomainId: existingClaim.id, selector: existingClaim.selector }));
+
+ logEmailDomainTransition({
+ event: 'takeover',
+ emailDomainId: existingClaim.id,
+ organisationId: existingClaim.organisationId,
+ takingOverOrganisationId: organisationId,
+ domain: existingClaim.domain,
+ previousStatus: existingClaim.status,
+ nextStatus: null,
+ reason: `Pending claim was ${Math.floor(claimAgeMs / MS_PER_HOUR)}h old, exceeding the ${Math.floor(
+ STALE_PENDING_CLAIM_TTL_MS / MS_PER_HOUR,
+ )}h takeover window`,
+ });
+};
diff --git a/packages/lib/server-only/email-domain/domain-policy.test.ts b/packages/lib/server-only/email-domain/domain-policy.test.ts
new file mode 100644
index 0000000000..25ee2c64c1
--- /dev/null
+++ b/packages/lib/server-only/email-domain/domain-policy.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import {
+ assertDomainIsClaimable,
+ describeDomainPolicyViolation,
+ isDnsLegalLabel,
+ normaliseDomain,
+} from './domain-policy';
+
+describe('normaliseDomain', () => {
+ it('lowercases, trims and strips the trailing root dot', () => {
+ expect(normaliseDomain(' Example.COM. ')).toBe('example.com');
+ expect(normaliseDomain('example.com...')).toBe('example.com');
+ expect(normaliseDomain('example.com')).toBe('example.com');
+ });
+});
+
+describe('isDnsLegalLabel', () => {
+ it('accepts ordinary host labels', () => {
+ expect(isDnsLegalLabel('a')).toBe(true);
+ expect(isDnsLegalLabel('mail-01')).toBe(true);
+ expect(isDnsLegalLabel('x'.repeat(63))).toBe(true);
+ });
+
+ it('rejects labels that are not valid in DNS', () => {
+ expect(isDnsLegalLabel('')).toBe(false);
+ expect(isDnsLegalLabel('-lead')).toBe(false);
+ expect(isDnsLegalLabel('trail-')).toBe(false);
+ expect(isDnsLegalLabel('x'.repeat(64))).toBe(false);
+ expect(isDnsLegalLabel('under_score')).toBe(false);
+ expect(isDnsLegalLabel('spaced label')).toBe(false);
+ });
+});
+
+describe('describeDomainPolicyViolation', () => {
+ it('accepts an ordinary registrable domain', () => {
+ expect(describeDomainPolicyViolation('example.com')).toBeNull();
+ expect(describeDomainPolicyViolation('mail.example.co.uk')).toBeNull();
+ expect(describeDomainPolicyViolation('crove-sign.io')).toBeNull();
+ });
+
+ it.each([
+ 'gmail.com',
+ 'googlemail.com',
+ 'yahoo.com',
+ 'outlook.com',
+ 'hotmail.com',
+ 'live.com',
+ 'msn.com',
+ 'icloud.com',
+ 'me.com',
+ 'proton.me',
+ 'protonmail.com',
+ 'aol.com',
+ 'zoho.com',
+ 'gmx.net',
+ 'gmx.com',
+ 'mail.ru',
+ 'yandex.ru',
+ 'yandex.com',
+ 'fastmail.com',
+ ])('rejects the public mailbox provider %s', (domain) => {
+ expect(describeDomainPolicyViolation(domain)).toContain('Shared mailbox providers');
+ });
+
+ it('rejects single-label hosts', () => {
+ expect(describeDomainPolicyViolation('intranet')).toContain('single-label');
+ });
+
+ it('rejects a non-alphabetic top-level domain', () => {
+ expect(describeDomainPolicyViolation('example.c0m')).toContain('alphabetic');
+ expect(describeDomainPolicyViolation('example.123')).toContain('alphabetic');
+ });
+
+ it('rejects a www. prefixed host', () => {
+ expect(describeDomainPolicyViolation('www.example.com')).toContain('www');
+ });
+
+ it('rejects domains containing an illegal label', () => {
+ expect(describeDomainPolicyViolation('exa_mple.com')).toContain('not valid in DNS');
+ expect(describeDomainPolicyViolation('-example.com')).toContain('not valid in DNS');
+ });
+
+ it('rejects an empty domain', () => {
+ expect(describeDomainPolicyViolation('')).toContain('required');
+ });
+});
+
+describe('assertDomainIsClaimable', () => {
+ it('returns the normalised domain when it can be claimed', () => {
+ expect(assertDomainIsClaimable('Example.COM.')).toBe('example.com');
+ });
+
+ it('throws INVALID_BODY when policy forbids the claim', () => {
+ let caught: unknown;
+
+ try {
+ assertDomainIsClaimable('gmail.com');
+ } catch (error) {
+ caught = error;
+ }
+
+ const appError = AppError.parseError(caught);
+
+ expect(appError.code).toBe(AppErrorCode.INVALID_BODY);
+ expect(appError.userMessage).toContain('Shared mailbox providers');
+ });
+});
diff --git a/packages/lib/server-only/email-domain/domain-policy.ts b/packages/lib/server-only/email-domain/domain-policy.ts
new file mode 100644
index 0000000000..1f9d4e0493
--- /dev/null
+++ b/packages/lib/server-only/email-domain/domain-policy.ts
@@ -0,0 +1,112 @@
+import { domainToUnicode } from 'node:url';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { BLOCKED_SENDING_DOMAINS, MAX_DNS_LABEL_LENGTH, PUNYCODE_LABEL_PREFIX } from './constants';
+
+const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
+const ALPHABETIC_TLD_PATTERN = /^[a-z]{2,}$/;
+const WWW_PREFIX = 'www.';
+
+/**
+ * Lowercase, trim and drop the trailing root dot so that `EXAMPLE.com.` and
+ * `example.com` are the same claim.
+ */
+export const normaliseDomain = (domain: string): string => {
+ return domain.trim().toLowerCase().replace(/\.+$/, '');
+};
+
+/**
+ * RFC 1035 label rules as they apply to names we publish or store: 1-63 octets,
+ * alphanumerics and interior hyphens only, never starting or ending with a
+ * hyphen.
+ */
+export const isDnsLegalLabel = (label: string): boolean => {
+ return label.length > 0 && label.length <= MAX_DNS_LABEL_LENGTH && DNS_LABEL_PATTERN.test(label);
+};
+
+/**
+ * A cheap homograph guard.
+ *
+ * Full mixed-script detection needs Unicode script tables; this catches the case
+ * that actually gets used for impersonation — a punycode label that decodes to a
+ * mixture of ASCII letters and non-ASCII lookalikes. Labels that are entirely
+ * non-ASCII are legitimate internationalised domains and are left alone, as are
+ * labels we cannot decode (including on Node builds without full ICU, where
+ * `domainToUnicode` gives up rather than echoing).
+ */
+const isMixedScriptPunycodeLabel = (label: string): boolean => {
+ if (!label.startsWith(PUNYCODE_LABEL_PREFIX)) {
+ return false;
+ }
+
+ const decoded = domainToUnicode(label);
+
+ if (decoded === label || decoded.length === 0) {
+ return false;
+ }
+
+ const hasAsciiLetter = /[a-z]/i.test(decoded);
+ const hasNonAsciiCharacter = /[^\p{ASCII}]/u.test(decoded);
+
+ return hasAsciiLetter && hasNonAsciiCharacter;
+};
+
+/**
+ * Returns the reason a domain cannot be claimed, or null when it can.
+ */
+export const describeDomainPolicyViolation = (domain: string): string | null => {
+ if (domain.length === 0) {
+ return 'A domain is required.';
+ }
+
+ if (domain.startsWith(WWW_PREFIX)) {
+ return 'Enter the registrable domain without a www. prefix.';
+ }
+
+ const labels = domain.split('.');
+
+ if (labels.length < 2) {
+ return 'A single-label host cannot be used as a sending domain.';
+ }
+
+ if (labels.some((label) => !isDnsLegalLabel(label))) {
+ return 'The domain contains a label that is not valid in DNS.';
+ }
+
+ const tld = labels.at(-1) ?? '';
+
+ if (!ALPHABETIC_TLD_PATTERN.test(tld)) {
+ return 'The top-level domain must be alphabetic.';
+ }
+
+ if (BLOCKED_SENDING_DOMAINS.has(domain)) {
+ return 'Shared mailbox providers cannot be claimed as a sending domain.';
+ }
+
+ if (labels.some(isMixedScriptPunycodeLabel)) {
+ return 'The domain mixes writing systems in a way that could impersonate another domain.';
+ }
+
+ return null;
+};
+
+/**
+ * Normalise a domain and reject it when policy forbids claiming it.
+ *
+ * The tRPC layer already applies a regex and lowercases the value; this is the
+ * authoritative re-check for every other caller and for the invariants the regex
+ * does not express (blocked providers, single-label hosts, non-alphabetic TLDs).
+ */
+export const assertDomainIsClaimable = (domain: string): string => {
+ const normalised = normaliseDomain(domain);
+ const violation = describeDomainPolicyViolation(normalised);
+
+ if (violation) {
+ throw new AppError(AppErrorCode.INVALID_BODY, {
+ message: `Refusing to register email domain "${normalised}": ${violation}`,
+ userMessage: violation,
+ });
+ }
+
+ return normalised;
+};
diff --git a/packages/lib/server-only/email-domain/domain-verification.ts b/packages/lib/server-only/email-domain/domain-verification.ts
new file mode 100644
index 0000000000..790eee82fd
--- /dev/null
+++ b/packages/lib/server-only/email-domain/domain-verification.ts
@@ -0,0 +1,117 @@
+import { SES_SPF_MECHANISMS } from './constants';
+import { evaluateDkimProof } from './dkim-record';
+import { createDnsQueryBudget, flattenTxtRecord, readStartOfAuthorityState, readTxtRecords } from './dns';
+import { dkimRecordHostName } from './dns-records';
+import { isOwnershipChallengeSatisfied, ownershipChallengeHostName } from './ownership-challenge';
+
+export type DomainDnsVerificationResult =
+ /**
+ * Both mandatory proofs are published and correct.
+ */
+ | { kind: 'satisfied'; hasSpfRecord: boolean }
+ /**
+ * An authoritative answer says the configuration is wrong. Safe to count
+ * towards a demotion.
+ */
+ | { kind: 'unsatisfied'; reason: string }
+ /**
+ * We could not get an authoritative answer. Says nothing about the domain and
+ * must never be counted towards a demotion.
+ */
+ | { kind: 'inconclusive'; reason: string };
+
+export type CheckDomainDnsConfigurationOptions = {
+ domain: string;
+ selector: string;
+ publicKey: string;
+ ownershipChallengeValue: string;
+};
+
+/**
+ * Whether a TXT answer authorises Amazon SES to send for the domain.
+ *
+ * Mechanisms are compared as whole tokens because `-include:amazonses.com`
+ * actively disauthorises SES and must not be mistaken for the pass form.
+ */
+export const hasAuthorisingSpfRecord = (records: string[][]): boolean => {
+ return records.some((chunks) => {
+ // Trim first: a leading space would make the first token an empty string
+ // and the `v=spf1` test below would silently fail.
+ const tokens = flattenTxtRecord(chunks).trim().split(/\s+/);
+ const isSpfRecord = (tokens.at(0) ?? '').toLowerCase() === 'v=spf1';
+
+ return isSpfRecord && tokens.slice(1).some((token) => SES_SPF_MECHANISMS.has(token.toLowerCase()));
+ });
+};
+
+export const checkDomainDnsConfiguration = async ({
+ domain,
+ selector,
+ publicKey,
+ ownershipChallengeValue,
+}: CheckDomainDnsConfigurationOptions): Promise => {
+ const budget = createDnsQueryBudget();
+
+ const [challengeOutcome, dkimOutcome, spfOutcome] = await Promise.all([
+ readTxtRecords(ownershipChallengeHostName(domain), budget),
+ readTxtRecords(dkimRecordHostName(selector, domain), budget),
+ readTxtRecords(domain, budget),
+ ]);
+
+ if (challengeOutcome.kind === 'unavailable') {
+ return {
+ kind: 'inconclusive',
+ reason: `DNS could not answer for the ownership challenge record (${challengeOutcome.code})`,
+ };
+ }
+
+ if (dkimOutcome.kind === 'unavailable') {
+ return {
+ kind: 'inconclusive',
+ reason: `DNS could not answer for the DKIM record (${dkimOutcome.code})`,
+ };
+ }
+
+ const challengeAnswer = challengeOutcome.kind === 'answered' ? challengeOutcome.value : null;
+ const dkimAnswer = dkimOutcome.kind === 'answered' ? dkimOutcome.value : null;
+
+ if (challengeAnswer === null || dkimAnswer === null) {
+ // An NXDOMAIN for a required name only counts as proof of absence if the
+ // resolver can still answer for the zone itself; otherwise a dead resolver
+ // would look exactly like a record the administrator removed.
+ const soaState = await readStartOfAuthorityState(domain, budget);
+
+ if (soaState === 'unavailable') {
+ return {
+ kind: 'inconclusive',
+ reason: 'Required records were reported missing but the resolver could not confirm the zone exists',
+ };
+ }
+
+ return {
+ kind: 'unsatisfied',
+ reason:
+ soaState === 'absent' ? 'The domain does not resolve in DNS at all' : 'A required DNS record does not exist',
+ };
+ }
+
+ if (!isOwnershipChallengeSatisfied(challengeAnswer, ownershipChallengeValue)) {
+ return {
+ kind: 'unsatisfied',
+ reason: 'The ownership challenge TXT record is missing or does not match exactly',
+ };
+ }
+
+ const dkimProof = evaluateDkimProof(dkimAnswer, publicKey);
+
+ if (!dkimProof.isProven) {
+ return { kind: 'unsatisfied', reason: dkimProof.reason };
+ }
+
+ // SPF is advisory: administrators frequently already run an SPF record and have
+ // to merge our include into it, and SES enforces sending authorisation itself.
+ // Gating activation on it would reject domains that sign correctly.
+ const hasSpfRecord = spfOutcome.kind === 'answered' && hasAuthorisingSpfRecord(spfOutcome.value);
+
+ return { kind: 'satisfied', hasSpfRecord };
+};
diff --git a/packages/lib/server-only/email-domain/key-material.ts b/packages/lib/server-only/email-domain/key-material.ts
new file mode 100644
index 0000000000..ec74d659e3
--- /dev/null
+++ b/packages/lib/server-only/email-domain/key-material.ts
@@ -0,0 +1,27 @@
+import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { symmetricEncrypt } from '../../universal/crypto';
+
+/**
+ * The DKIM private key is the only thing that lets whoever holds it sign mail as
+ * the domain, so it is encrypted with the installation's symmetric key before it
+ * ever reaches the database. Refusing to run without that key is deliberate:
+ * writing a bare private key would be an unrecoverable exposure.
+ */
+export const assertEmailDomainEncryptionKey = (): string => {
+ if (!DOCUMENSO_ENCRYPTION_KEY) {
+ throw new AppError(AppErrorCode.MISSING_ENV_VAR, {
+ message: 'NEXT_PRIVATE_ENCRYPTION_KEY is not configured, so DKIM key material cannot be stored safely.',
+ userMessage: 'Custom sending domains are not fully configured on this installation.',
+ });
+ }
+
+ return DOCUMENSO_ENCRYPTION_KEY;
+};
+
+export const encryptDkimPrivateKey = (privateKeyPem: string): string => {
+ return symmetricEncrypt({
+ key: assertEmailDomainEncryptionKey(),
+ data: privateKeyPem,
+ });
+};
diff --git a/packages/lib/server-only/email-domain/ownership-challenge.test.ts b/packages/lib/server-only/email-domain/ownership-challenge.test.ts
new file mode 100644
index 0000000000..1edf4139a9
--- /dev/null
+++ b/packages/lib/server-only/email-domain/ownership-challenge.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest';
+
+import { isConstantTimeEqual } from './constant-time';
+import {
+ buildOwnershipChallengeRecord,
+ buildOwnershipChallengeRecordValue,
+ deriveOwnershipChallengeToken,
+ isOwnershipChallengeSatisfied,
+ ownershipChallengeHostName,
+} from './ownership-challenge';
+
+const ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+const SUBJECT = {
+ emailDomainId: 'email_domain_challenge',
+ selector: 'crove-challengetst._domainkey',
+ domain: 'example.com',
+};
+
+describe('isConstantTimeEqual', () => {
+ it('is an exact equality test', () => {
+ expect(isConstantTimeEqual('same', 'same')).toBe(true);
+ expect(isConstantTimeEqual('same', 'Same')).toBe(false);
+ expect(isConstantTimeEqual('same', 'sam')).toBe(false);
+ expect(isConstantTimeEqual('same', 'samee')).toBe(false);
+ expect(isConstantTimeEqual('', '')).toBe(true);
+ expect(isConstantTimeEqual('', 'x')).toBe(false);
+ });
+
+ it('handles inputs of very different lengths without throwing', () => {
+ expect(isConstantTimeEqual('a'.repeat(4096), 'a')).toBe(false);
+ expect(isConstantTimeEqual('a'.repeat(4096), 'a'.repeat(4096))).toBe(true);
+ });
+});
+
+describe('deriveOwnershipChallengeToken', () => {
+ it('carries at least 128 bits of entropy', () => {
+ const token = deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY);
+
+ // base64url of a 32-byte HMAC-SHA256 digest.
+ expect(token).toHaveLength(43);
+ expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ });
+
+ it('is deterministic for the same subject and key', () => {
+ expect(deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY)).toBe(
+ deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY),
+ );
+ });
+
+ it('rotates with the selector so a reregistration invalidates the old challenge', () => {
+ const rotated = deriveOwnershipChallengeToken(
+ { ...SUBJECT, selector: 'crove-rotated00001._domainkey' },
+ ENCRYPTION_KEY,
+ );
+
+ expect(rotated).not.toBe(deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY));
+ });
+
+ it('cannot be produced from another installation key', () => {
+ expect(deriveOwnershipChallengeToken(SUBJECT, 'a-different-encryption-key')).not.toBe(
+ deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY),
+ );
+ });
+
+ it('differs per row even when the selector and domain collide', () => {
+ expect(deriveOwnershipChallengeToken({ ...SUBJECT, emailDomainId: 'email_domain_other' }, ENCRYPTION_KEY)).not.toBe(
+ deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY),
+ );
+ });
+});
+
+describe('the ownership challenge record', () => {
+ it('is published at a deterministic host and carries the token', () => {
+ const token = deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY);
+ const record = buildOwnershipChallengeRecord(token);
+
+ expect(ownershipChallengeHostName('example.com')).toBe('_crove-verify.example.com');
+ expect(record.name).toBe('_crove-verify');
+ expect(record.type).toBe('TXT');
+ expect(record.value).toBe(`crove-domain-verification=${token}`);
+ expect(record.value).toBe(buildOwnershipChallengeRecordValue(token));
+ });
+});
+
+describe('isOwnershipChallengeSatisfied', () => {
+ const token = deriveOwnershipChallengeToken(SUBJECT, ENCRYPTION_KEY);
+ const expected = buildOwnershipChallengeRecordValue(token);
+
+ it('accepts an exactly matching record', () => {
+ expect(isOwnershipChallengeSatisfied([[expected]], expected)).toBe(true);
+ });
+
+ it('accepts a value split across character-strings or padded with whitespace', () => {
+ expect(isOwnershipChallengeSatisfied([[expected.slice(0, 20), expected.slice(20)]], expected)).toBe(true);
+ expect(isOwnershipChallengeSatisfied([[` ${expected} `]], expected)).toBe(true);
+ });
+
+ it('accepts the match wherever it sits in the answer set', () => {
+ expect(isOwnershipChallengeSatisfied([['unrelated'], [expected]], expected)).toBe(true);
+ });
+
+ it('rejects a tampered, truncated or extended value', () => {
+ const mutatedLastCharacter = `${expected.slice(0, -1)}${expected.endsWith('A') ? 'B' : 'A'}`;
+
+ expect(isOwnershipChallengeSatisfied([[`${expected}x`]], expected)).toBe(false);
+ expect(isOwnershipChallengeSatisfied([[expected.slice(0, -1)]], expected)).toBe(false);
+ expect(isOwnershipChallengeSatisfied([[mutatedLastCharacter]], expected)).toBe(false);
+ });
+
+ it('rejects the bare token without its prefix', () => {
+ expect(isOwnershipChallengeSatisfied([[token]], expected)).toBe(false);
+ });
+
+ it('rejects an empty answer set', () => {
+ expect(isOwnershipChallengeSatisfied([], expected)).toBe(false);
+ });
+});
diff --git a/packages/lib/server-only/email-domain/ownership-challenge.ts b/packages/lib/server-only/email-domain/ownership-challenge.ts
new file mode 100644
index 0000000000..afb54236a1
--- /dev/null
+++ b/packages/lib/server-only/email-domain/ownership-challenge.ts
@@ -0,0 +1,72 @@
+import { createHmac } from 'node:crypto';
+
+import { isConstantTimeEqual } from './constant-time';
+import {
+ OWNERSHIP_CHALLENGE_HMAC_CONTEXT,
+ OWNERSHIP_CHALLENGE_LABEL,
+ OWNERSHIP_CHALLENGE_VALUE_PREFIX,
+} from './constants';
+import { flattenTxtRecord } from './dns';
+import type { EmailDomainDnsRecord } from './types';
+
+export type OwnershipChallengeSubject = {
+ emailDomainId: string;
+ selector: string;
+ domain: string;
+};
+
+/**
+ * Derive the ownership-challenge token for a row.
+ *
+ * The token is an HMAC over the row's identity under the installation's
+ * encryption key rather than a stored random value: it carries 256 bits of
+ * entropy, needs no schema column, survives restarts, and can be recomputed on
+ * every verification. Because the key never leaves the server, an attacker who
+ * can read the row — or the DNS zone — still cannot produce the token.
+ *
+ * Including the selector means reregistration rotates the challenge along with
+ * the DKIM key, so a token captured from an abandoned setup cannot be replayed
+ * against a fresh one.
+ */
+export const deriveOwnershipChallengeToken = (subject: OwnershipChallengeSubject, encryptionKey: string): string => {
+ const { emailDomainId, selector, domain } = subject;
+
+ return createHmac('sha256', encryptionKey)
+ .update(`${OWNERSHIP_CHALLENGE_HMAC_CONTEXT}:${emailDomainId}:${selector}:${domain}`)
+ .digest('base64url');
+};
+
+export const buildOwnershipChallengeRecordValue = (token: string): string => {
+ return `${OWNERSHIP_CHALLENGE_VALUE_PREFIX}${token}`;
+};
+
+export const buildOwnershipChallengeRecord = (token: string): EmailDomainDnsRecord => {
+ return {
+ name: OWNERSHIP_CHALLENGE_LABEL,
+ value: buildOwnershipChallengeRecordValue(token),
+ type: 'TXT',
+ };
+};
+
+export const ownershipChallengeHostName = (domain: string): string => {
+ return `${OWNERSHIP_CHALLENGE_LABEL}.${domain}`;
+};
+
+/**
+ * Exact-match check over every TXT record published at the challenge host.
+ *
+ * Each candidate is compared in full and in constant time; the loop deliberately
+ * does not short-circuit so that the number of comparisons does not depend on
+ * where in the answer set the match sits.
+ */
+export const isOwnershipChallengeSatisfied = (records: string[][], expectedValue: string): boolean => {
+ let isSatisfied = false;
+
+ for (const chunks of records) {
+ if (isConstantTimeEqual(flattenTxtRecord(chunks).trim(), expectedValue)) {
+ isSatisfied = true;
+ }
+ }
+
+ return isSatisfied;
+};
diff --git a/packages/lib/server-only/email-domain/prisma-conflict.ts b/packages/lib/server-only/email-domain/prisma-conflict.ts
new file mode 100644
index 0000000000..5346a88091
--- /dev/null
+++ b/packages/lib/server-only/email-domain/prisma-conflict.ts
@@ -0,0 +1,45 @@
+import { Prisma } from '@prisma/client';
+import { z } from 'zod';
+
+const PRISMA_UNIQUE_CONSTRAINT_CODE = 'P2002';
+
+const readConflictTarget = (meta: unknown): string => {
+ const asArray = z.array(z.string()).safeParse(meta);
+
+ if (asArray.success) {
+ return asArray.data.join(',');
+ }
+
+ const asString = z.string().safeParse(meta);
+
+ if (asString.success) {
+ return asString.data;
+ }
+
+ return '';
+};
+
+/**
+ * The column a unique-constraint violation was raised against, or null when the
+ * error is something else entirely.
+ *
+ * Prisma reports the target either as a list of columns or as the constraint
+ * name, so both shapes are reduced to one searchable string.
+ */
+export const readPrismaUniqueConflictTarget = (error: unknown): string | null => {
+ if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
+ return null;
+ }
+
+ if (error.code !== PRISMA_UNIQUE_CONSTRAINT_CODE) {
+ return null;
+ }
+
+ return readConflictTarget(error.meta?.target);
+};
+
+export const isPrismaConflictOn = (error: unknown, column: string): boolean => {
+ const target = readPrismaUniqueConflictTarget(error);
+
+ return target?.includes(column) ?? false;
+};
diff --git a/packages/lib/server-only/email-domain/reregister-email-domain.test.ts b/packages/lib/server-only/email-domain/reregister-email-domain.test.ts
new file mode 100644
index 0000000000..9613e03c05
--- /dev/null
+++ b/packages/lib/server-only/email-domain/reregister-email-domain.test.ts
@@ -0,0 +1,225 @@
+import { EmailDomainStatus } from '@prisma/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+ process.env.NEXT_PRIVATE_SES_ACCESS_KEY_ID = 'cleanroom-access-key';
+ process.env.NEXT_PRIVATE_SES_SECRET_ACCESS_KEY = 'cleanroom-secret-key';
+ process.env.NEXT_PRIVATE_SES_REGION = 'us-east-1';
+});
+
+const mocks = vi.hoisted(() => ({
+ emailDomainFindUnique: vi.fn(),
+ emailDomainCreate: vi.fn(),
+ emailDomainUpdate: vi.fn(),
+ emailDomainDelete: vi.fn(),
+ rateLimitUpsert: vi.fn(),
+ sesSend: vi.fn(),
+ createEmailIdentityCommand: vi.fn(),
+ deleteEmailIdentityCommand: vi.fn(),
+ getEmailIdentityCommand: vi.fn(),
+ putDkimSigningAttributesCommand: vi.fn(),
+ resolveTxt: vi.fn(),
+ resolveSoa: vi.fn(),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ logError: vi.fn(),
+}));
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ emailDomain: {
+ findUnique: mocks.emailDomainFindUnique,
+ create: mocks.emailDomainCreate,
+ update: mocks.emailDomainUpdate,
+ delete: mocks.emailDomainDelete,
+ },
+ rateLimit: {
+ upsert: mocks.rateLimitUpsert,
+ },
+ },
+}));
+
+// `SESv2Client` is a class because production code constructs it; an arrow
+// function cannot be constructed.
+vi.mock('@aws-sdk/client-sesv2', () => ({
+ SESv2Client: vi.fn(
+ class {
+ send = mocks.sesSend;
+ },
+ ),
+ CreateEmailIdentityCommand: mocks.createEmailIdentityCommand,
+ DeleteEmailIdentityCommand: mocks.deleteEmailIdentityCommand,
+ GetEmailIdentityCommand: mocks.getEmailIdentityCommand,
+ PutEmailIdentityDkimSigningAttributesCommand: mocks.putDkimSigningAttributesCommand,
+}));
+
+vi.mock('node:dns/promises', () => ({
+ resolveTxt: mocks.resolveTxt,
+ resolveSoa: mocks.resolveSoa,
+}));
+
+vi.mock('../../utils/logger', () => ({
+ logger: {
+ info: mocks.logInfo,
+ warn: mocks.logWarn,
+ error: mocks.logError,
+ },
+}));
+
+import { AppErrorCode } from '../../errors/app-error';
+import { symmetricDecrypt } from '../../universal/crypto';
+import { reregisterEmailDomain } from './reregister-email-domain';
+
+const ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+const EMAIL_DOMAIN_ID = 'email_domain_reregister_target';
+const DOMAIN = 'example.com';
+const ORGANISATION_ID = 'org_cleanroom_reregister';
+const ORIGINAL_SELECTOR = 'crove-originalsel1._domainkey';
+const ORIGINAL_PUBLIC_KEY = 'ORIGINALPUBLICKEYMATERIAL';
+
+const buildRow = (overrides: Record = {}) => ({
+ id: EMAIL_DOMAIN_ID,
+ domain: DOMAIN,
+ selector: ORIGINAL_SELECTOR,
+ publicKey: ORIGINAL_PUBLIC_KEY,
+ privateKey: 'original-encrypted-private-key',
+ status: EmailDomainStatus.PENDING,
+ organisationId: ORGANISATION_ID,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+ lastVerifiedAt: new Date('2026-02-01T00:00:00.000Z'),
+ ...overrides,
+});
+
+type UpdatePayload = {
+ where: { id: string };
+ data: Record;
+};
+
+const readUpdatePayload = (callIndex: number): UpdatePayload => {
+ const call = mocks.emailDomainUpdate.mock.calls.at(callIndex);
+ const payload = call?.[0] as UpdatePayload | undefined;
+
+ return payload ?? { where: { id: '' }, data: {} };
+};
+
+describe('reregisterEmailDomain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.emailDomainFindUnique.mockResolvedValue(buildRow());
+ mocks.emailDomainUpdate.mockResolvedValue(buildRow());
+ mocks.sesSend.mockResolvedValue({});
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('keeps the row id while rotating the selector and the key pair', async () => {
+ await reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ expect(mocks.emailDomainDelete).not.toHaveBeenCalled();
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledOnce();
+
+ const payload = readUpdatePayload(0);
+
+ expect(payload.where).toEqual({ id: EMAIL_DOMAIN_ID });
+ expect(payload.data.status).toBe(EmailDomainStatus.PENDING);
+ expect(payload.data.lastVerifiedAt).toBeNull();
+
+ const selector = String(payload.data.selector);
+
+ expect(selector).not.toBe(ORIGINAL_SELECTOR);
+ expect(selector).toMatch(/^crove-[a-z0-9]{12}\._domainkey$/);
+ expect(String(payload.data.publicKey)).not.toBe(ORIGINAL_PUBLIC_KEY);
+ expect(String(payload.data.privateKey)).not.toContain('BEGIN PRIVATE KEY');
+
+ const decryptedPrivateKey = Buffer.from(
+ symmetricDecrypt({ key: ENCRYPTION_KEY, data: String(payload.data.privateKey) }),
+ ).toString('utf8');
+
+ expect(decryptedPrivateKey).toContain('BEGIN PRIVATE KEY');
+ });
+
+ it('points Amazon SES at the freshly generated selector', async () => {
+ await reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ const selectorLabel = String(readUpdatePayload(0).data.selector).replace('._domainkey', '');
+
+ expect(mocks.createEmailIdentityCommand).toHaveBeenCalledWith({ EmailIdentity: DOMAIN });
+ expect(mocks.putDkimSigningAttributesCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ EmailIdentity: DOMAIN,
+ SigningAttributesOrigin: 'EXTERNAL',
+ SigningAttributes: expect.objectContaining({ DomainSigningSelector: selectorLabel }),
+ }),
+ );
+ });
+
+ it('leaves the stored row alone when Amazon SES refuses the rotation', async () => {
+ mocks.sesSend.mockRejectedValue(
+ Object.assign(new Error('BadRequestException'), {
+ name: 'BadRequestException',
+ $metadata: { httpStatusCode: 400, requestId: 'aws-request-id' },
+ }),
+ );
+
+ await expect(reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID })).rejects.toMatchObject({
+ code: AppErrorCode.UNKNOWN_ERROR,
+ });
+
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('is safe to call repeatedly', async () => {
+ await reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+ await reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledTimes(2);
+ expect(readUpdatePayload(0).where).toEqual({ id: EMAIL_DOMAIN_ID });
+ expect(readUpdatePayload(1).where).toEqual({ id: EMAIL_DOMAIN_ID });
+ expect(String(readUpdatePayload(0).data.selector)).not.toBe(String(readUpdatePayload(1).data.selector));
+ expect(mocks.emailDomainCreate).not.toHaveBeenCalled();
+ expect(mocks.emailDomainDelete).not.toHaveBeenCalled();
+ });
+
+ it('logs the rotation as one structured audit transition', async () => {
+ await reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID });
+
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ msg: 'email_domain_transition',
+ event: 'reregistered',
+ emailDomainId: EMAIL_DOMAIN_ID,
+ organisationId: ORGANISATION_ID,
+ domain: DOMAIN,
+ previousStatus: EmailDomainStatus.PENDING,
+ nextStatus: EmailDomainStatus.PENDING,
+ }),
+ );
+ });
+
+ it('throws NOT_FOUND for an unknown id', async () => {
+ mocks.emailDomainFindUnique.mockResolvedValue(null);
+
+ await expect(reregisterEmailDomain({ emailDomainId: 'email_domain_missing' })).rejects.toMatchObject({
+ code: AppErrorCode.NOT_FOUND,
+ });
+
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('throws NOT_SETUP when Amazon SES is unconfigured', async () => {
+ vi.stubEnv('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY', '');
+
+ await expect(reregisterEmailDomain({ emailDomainId: EMAIL_DOMAIN_ID })).rejects.toMatchObject({
+ code: AppErrorCode.NOT_SETUP,
+ });
+
+ expect(mocks.emailDomainFindUnique).not.toHaveBeenCalled();
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/server-only/email-domain/reregister-email-domain.ts b/packages/lib/server-only/email-domain/reregister-email-domain.ts
index b18d4db3ec..79fecf8b2b 100644
--- a/packages/lib/server-only/email-domain/reregister-email-domain.ts
+++ b/packages/lib/server-only/email-domain/reregister-email-domain.ts
@@ -1,26 +1,47 @@
-import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
-import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
-import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { EmailDomainStatus } from '@prisma/client';
-import { getSesClient, verifyDomainWithDKIM } from './create-email-domain';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { logEmailDomainTransition } from './audit';
+import type { GeneratedDkimKeyPair } from './dkim-keys';
+import { generateDkimKeyPair } from './dkim-keys';
+import { assertEmailDomainEncryptionKey, encryptDkimPrivateKey } from './key-material';
+import { isPrismaConflictOn } from './prisma-conflict';
+import { assertSesServiceConfigured } from './ses-client';
+import { registerSesEmailIdentity } from './ses-identity';
+import { buildNegativeStreakKey, clearNegativeStreak } from './verification-state';
-type ReregisterEmailDomainOptions = {
+const MAX_REREGISTER_ATTEMPTS = 2;
+
+export type ReregisterEmailDomainOptions = {
emailDomainId: string;
};
-export const reregisterEmailDomain = async ({ emailDomainId }: ReregisterEmailDomainOptions) => {
- const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
+/**
+ * Rotate a stalled domain's key material and hand the administrator a fresh set of
+ * records to publish (F5).
+ *
+ * The row id is preserved so that any `OrganisationEmail` addresses already
+ * created against the domain survive; only the selector, the key pair and the
+ * derived ownership challenge change. Calling it repeatedly is harmless — each
+ * call simply supersedes the previous rotation — which matters because the hourly
+ * sync job re-registers anything that has been PENDING for more than 48 hours.
+ */
+export const reregisterEmailDomain = async ({ emailDomainId }: ReregisterEmailDomainOptions): Promise => {
+ assertSesServiceConfigured();
- if (!encryptionKey) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
- }
+ // Fails before anything is written when the rotated private key could not be
+ // stored encrypted.
+ assertEmailDomainEncryptionKey();
const emailDomain = await prisma.emailDomain.findUnique({
- where: {
- id: emailDomainId,
+ where: { id: emailDomainId },
+ select: {
+ id: true,
+ domain: true,
+ selector: true,
+ status: true,
+ organisationId: true,
},
});
@@ -30,49 +51,55 @@ export const reregisterEmailDomain = async ({ emailDomainId }: ReregisterEmailDo
});
}
- const sesClient = getSesClient();
+ let rotatedKeyPair: GeneratedDkimKeyPair | null = null;
- if (sesClient) {
- await sesClient
- .send(
- new DeleteEmailIdentityCommand({
- EmailIdentity: emailDomain.domain,
- }),
- )
- .catch((err) => {
- if (err.name !== 'NotFoundException') {
- console.warn('[Email Domain] Failed to delete existing SES identity during reregister:', err);
- }
- });
- }
+ for (let attempt = 1; attempt <= MAX_REREGISTER_ATTEMPTS && rotatedKeyPair === null; attempt++) {
+ const keyPair = generateDkimKeyPair();
- const decryptedPrivateKeyBytes = symmetricDecrypt({
- key: encryptionKey,
- data: emailDomain.privateKey,
- });
+ // SES is re-pointed before the row is touched: if signing cannot be
+ // reconfigured, the database keeps describing the key SES is actually using.
+ await registerSesEmailIdentity({
+ domain: emailDomain.domain,
+ selectorLabel: keyPair.selectorLabel,
+ privateKeyPem: keyPair.privateKeyPem,
+ });
- const decryptedPrivateKey = new TextDecoder().decode(decryptedPrivateKeyBytes);
+ try {
+ await prisma.emailDomain.update({
+ where: { id: emailDomain.id },
+ data: {
+ selector: keyPair.selector,
+ publicKey: keyPair.publicKeyFlattened,
+ privateKey: encryptDkimPrivateKey(keyPair.privateKeyPem),
+ status: EmailDomainStatus.PENDING,
+ lastVerifiedAt: null,
+ },
+ });
- const selectorParts = emailDomain.selector.split('._domainkey.');
- const selector = selectorParts[0];
+ rotatedKeyPair = keyPair;
+ } catch (error) {
+ if (!isPrismaConflictOn(error, 'selector')) {
+ throw error;
+ }
+ }
+ }
- if (!selector) {
- throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
- message: 'Could not extract selector from email domain record',
+ if (!rotatedKeyPair) {
+ throw new AppError(AppErrorCode.RETRY_EXCEPTION, {
+ message: 'Could not allocate a unique DKIM selector while re-registering the email domain.',
+ userMessage: 'We could not refresh this domain. Please try again.',
});
}
- await verifyDomainWithDKIM(emailDomain.domain, selector, decryptedPrivateKey);
+ clearNegativeStreak(buildNegativeStreakKey({ emailDomainId: emailDomain.id, selector: emailDomain.selector }));
- const updatedEmailDomain = await prisma.emailDomain.update({
- where: {
- id: emailDomainId,
- },
- data: {
- status: EmailDomainStatus.PENDING,
- lastVerifiedAt: new Date(),
- },
+ logEmailDomainTransition({
+ event: 'reregistered',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ previousStatus: emailDomain.status,
+ nextStatus: EmailDomainStatus.PENDING,
+ reason: 'DKIM key pair, selector and ownership challenge rotated',
});
-
- return updatedEmailDomain;
};
diff --git a/packages/lib/server-only/email-domain/ses-client.ts b/packages/lib/server-only/email-domain/ses-client.ts
new file mode 100644
index 0000000000..16728c5306
--- /dev/null
+++ b/packages/lib/server-only/email-domain/ses-client.ts
@@ -0,0 +1,213 @@
+import { SESv2Client } from '@aws-sdk/client-sesv2';
+import { z } from 'zod';
+
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { env } from '../../utils/env';
+import { logger } from '../../utils/logger';
+import { externalOperationSemaphore } from './concurrency';
+
+export type SesServiceConfiguration = {
+ accessKeyId: string;
+ secretAccessKey: string;
+ region: string;
+};
+
+const ZAwsErrorSchema = z.object({
+ name: z.string().optional(),
+ // Socket and resolver failures from Node carry their reason in `code` rather
+ // than `name`, so both have to be read to classify a failure at all.
+ code: z.string().optional(),
+ message: z.string().optional(),
+ $metadata: z
+ .object({
+ httpStatusCode: z.number().optional(),
+ requestId: z.string().optional(),
+ })
+ .optional(),
+});
+
+/**
+ * Retries and account-level pauses are transient from our point of view: the
+ * request may well succeed unchanged, so nothing about the domain's DNS can be
+ * inferred from them.
+ */
+const TRANSIENT_SES_ERROR_NAMES: ReadonlySet = new Set([
+ 'TooManyRequestsException',
+ 'ThrottlingException',
+ 'ConcurrentModificationException',
+ 'AccountSendingPausedException',
+]);
+
+/**
+ * Failures that mean no HTTP response was ever received.
+ *
+ * This list is deliberately narrow. An error with no status code that is *not*
+ * one of these — a TypeError from our own code, an SDK misuse, an unrecognised
+ * exception — is a defect rather than an outage, and retrying it as though the
+ * network had hiccuped would turn a bug into an endless loop.
+ */
+const TRANSIENT_NETWORK_ERROR_NAMES: ReadonlySet = new Set([
+ 'TimeoutError',
+ 'NetworkingError',
+ 'RequestTimeout',
+ 'RequestTimeoutException',
+ 'ServiceUnavailable',
+ 'AbortError',
+ 'EAI_AGAIN',
+ 'ECONNABORTED',
+ 'ECONNREFUSED',
+ 'ECONNRESET',
+ 'EHOSTUNREACH',
+ 'ENETUNREACH',
+ 'ENOTFOUND',
+ 'EPIPE',
+ 'EPROTO',
+ 'ETIMEDOUT',
+]);
+
+export const readSesServiceConfiguration = (): SesServiceConfiguration | null => {
+ const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
+ const secretAccessKey = env('NEXT_PRIVATE_SES_SECRET_ACCESS_KEY');
+ const region = env('NEXT_PRIVATE_SES_REGION');
+
+ if (!accessKeyId || !secretAccessKey || !region) {
+ return null;
+ }
+
+ return { accessKeyId, secretAccessKey, region };
+};
+
+export const assertSesServiceConfigured = (): SesServiceConfiguration => {
+ const configuration = readSesServiceConfiguration();
+
+ if (!configuration) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Amazon SES is not configured. Set the NEXT_PRIVATE_SES_* access key, secret key and region variables.',
+ userMessage: 'Custom sending domains are not available on this installation.',
+ });
+ }
+
+ return configuration;
+};
+
+let cachedClient: { signature: string; client: SESv2Client } | null = null;
+
+/**
+ * The client is cached against the credentials it was built with so that a
+ * rotated key or region takes effect without a restart, and so that repeated
+ * verification passes do not each build a fresh HTTP agent.
+ */
+export const getSesClient = (): SESv2Client => {
+ const configuration = assertSesServiceConfigured();
+ const signature = `${configuration.region}:${configuration.accessKeyId}`;
+
+ if (cachedClient && cachedClient.signature === signature) {
+ return cachedClient.client;
+ }
+
+ const client = new SESv2Client({
+ region: configuration.region,
+ credentials: {
+ accessKeyId: configuration.accessKeyId,
+ secretAccessKey: configuration.secretAccessKey,
+ },
+ maxAttempts: 3,
+ });
+
+ cachedClient = { signature, client };
+
+ return client;
+};
+
+export const withSesClient = async (
+ operation: (client: SESv2Client) => Promise,
+): Promise => {
+ const client = getSesClient();
+
+ return await externalOperationSemaphore.run(() => operation(client));
+};
+
+export type AwsErrorDetails = {
+ name: string;
+ code: string | null;
+ message: string;
+ requestId: string | null;
+ httpStatusCode: number | null;
+};
+
+export const describeAwsError = (error: unknown): AwsErrorDetails => {
+ const parsed = ZAwsErrorSchema.safeParse(error);
+
+ if (!parsed.success) {
+ return {
+ name: 'UnknownError',
+ code: null,
+ message: 'Unrecognised Amazon SES failure',
+ requestId: null,
+ httpStatusCode: null,
+ };
+ }
+
+ return {
+ name: parsed.data.name ?? 'UnknownError',
+ code: parsed.data.code ?? null,
+ message: parsed.data.message ?? 'Amazon SES returned no message',
+ requestId: parsed.data.$metadata?.requestId ?? null,
+ httpStatusCode: parsed.data.$metadata?.httpStatusCode ?? null,
+ };
+};
+
+export const isSesErrorName = (error: unknown, name: string): boolean => {
+ return describeAwsError(error).name === name;
+};
+
+/**
+ * A failure is transient when SES told us to retry, when it returned a 5xx, or
+ * when a recognisable network-level error stopped us getting any HTTP response.
+ */
+export const isTransientSesError = (error: unknown): boolean => {
+ const { name, code, httpStatusCode } = describeAwsError(error);
+
+ if (TRANSIENT_SES_ERROR_NAMES.has(name) || TRANSIENT_NETWORK_ERROR_NAMES.has(name)) {
+ return true;
+ }
+
+ if (code !== null && TRANSIENT_NETWORK_ERROR_NAMES.has(code)) {
+ return true;
+ }
+
+ if (httpStatusCode === null) {
+ return false;
+ }
+
+ return httpStatusCode >= 500;
+};
+
+/**
+ * SES failures are logged in full — including the AWS request id, which is the
+ * only thing AWS support can act on — and reduced to a generic message for the
+ * client, since SES messages can carry account identifiers and quota details.
+ */
+export const logSesError = (action: string, error: unknown): void => {
+ const { name, message, requestId, httpStatusCode } = describeAwsError(error);
+
+ logger.error({
+ msg: 'email_domain_ses_error',
+ action,
+ errorName: name,
+ errorMessage: message,
+ awsRequestId: requestId,
+ httpStatusCode,
+ });
+};
+
+export const toSesAppError = (action: string, error: unknown): AppError => {
+ logSesError(action, error);
+
+ const errorCode = isTransientSesError(error) ? AppErrorCode.RETRY_EXCEPTION : AppErrorCode.UNKNOWN_ERROR;
+
+ return new AppError(errorCode, {
+ message: `Amazon SES refused to ${action}.`,
+ userMessage: 'We could not set up this domain with our email provider. Please try again later.',
+ });
+};
diff --git a/packages/lib/server-only/email-domain/ses-identity.ts b/packages/lib/server-only/email-domain/ses-identity.ts
new file mode 100644
index 0000000000..5cd27df8d4
--- /dev/null
+++ b/packages/lib/server-only/email-domain/ses-identity.ts
@@ -0,0 +1,194 @@
+import {
+ CreateEmailIdentityCommand,
+ DeleteEmailIdentityCommand,
+ GetEmailIdentityCommand,
+ PutEmailIdentityDkimSigningAttributesCommand,
+} from '@aws-sdk/client-sesv2';
+import { z } from 'zod';
+
+import { logger } from '../../utils/logger';
+import {
+ describeAwsError,
+ isSesErrorName,
+ logSesError,
+ readSesServiceConfiguration,
+ toSesAppError,
+ withSesClient,
+} from './ses-client';
+
+const DKIM_SIGNING_ATTRIBUTES_ORIGIN_EXTERNAL = 'EXTERNAL';
+const SES_DKIM_STATUS_FAILED = 'FAILED';
+
+const ZEmailIdentityResponseSchema = z.object({
+ IdentityType: z.string().optional(),
+ VerifiedForSendingStatus: z.boolean().optional(),
+ DkimAttributes: z
+ .object({
+ Status: z.string().optional(),
+ })
+ .optional(),
+});
+
+export type SesIdentityRemoval =
+ | { kind: 'removed' }
+ | { kind: 'absent' }
+ /**
+ * SES is not configured on this installation, so there is nothing to remove.
+ */
+ | { kind: 'skipped' }
+ /**
+ * SES could not be reached or refused the deletion. The database row is still
+ * removed and the identity is logged as an orphan for cleanup.
+ */
+ | { kind: 'failed'; reason: string };
+
+/**
+ * Remove the SES identity for a domain. Never throws: deleting our own row must
+ * not be blocked by the state of a third party.
+ */
+export const removeSesEmailIdentity = async ({ domain }: { domain: string }): Promise => {
+ if (!readSesServiceConfiguration()) {
+ return { kind: 'skipped' };
+ }
+
+ try {
+ await withSesClient((client) => client.send(new DeleteEmailIdentityCommand({ EmailIdentity: domain })));
+
+ return { kind: 'removed' };
+ } catch (error) {
+ if (isSesErrorName(error, 'NotFoundException')) {
+ return { kind: 'absent' };
+ }
+
+ logSesError('delete the sending domain identity', error);
+
+ return { kind: 'failed', reason: describeAwsError(error).name };
+ }
+};
+
+/**
+ * Log an SES identity that outlived its database row so that it can be cleaned up
+ * out of band.
+ */
+export const logOrphanedSesIdentity = ({
+ domain,
+ emailDomainId,
+ organisationId,
+ reason,
+}: {
+ domain: string;
+ emailDomainId: string;
+ organisationId: string;
+ reason: string;
+}): void => {
+ logger.error({
+ msg: 'email_domain_ses_orphan',
+ emailDomainId,
+ organisationId,
+ domain,
+ reason,
+ });
+};
+
+export type RegisterSesEmailIdentityOptions = {
+ domain: string;
+ selectorLabel: string;
+ privateKeyPem: string;
+};
+
+export type SesIdentityRegistration = {
+ /**
+ * False when SES already knew about the identity, which happens after a
+ * reregistration or when a previous attempt got as far as SES but not as far as
+ * the database.
+ */
+ didCreateIdentity: boolean;
+};
+
+/**
+ * Create the SES email identity for a domain and hand SES the private half of our
+ * BYODKIM key pair.
+ *
+ * Registering the identity is not sufficient on its own: SES-managed DKIM would
+ * sign with keys we never see and cannot verify against DNS, so the signing
+ * attributes are pointed at our own selector and key. Both steps must succeed or
+ * the domain cannot send.
+ */
+export const registerSesEmailIdentity = async ({
+ domain,
+ selectorLabel,
+ privateKeyPem,
+}: RegisterSesEmailIdentityOptions): Promise => {
+ let didCreateIdentity = false;
+
+ try {
+ await withSesClient((client) => client.send(new CreateEmailIdentityCommand({ EmailIdentity: domain })));
+
+ didCreateIdentity = true;
+ } catch (error) {
+ if (!isSesErrorName(error, 'AlreadyExistsException')) {
+ throw toSesAppError('register the sending domain', error);
+ }
+ }
+
+ try {
+ await withSesClient((client) =>
+ client.send(
+ new PutEmailIdentityDkimSigningAttributesCommand({
+ EmailIdentity: domain,
+ SigningAttributesOrigin: DKIM_SIGNING_ATTRIBUTES_ORIGIN_EXTERNAL,
+ SigningAttributes: {
+ DomainSigningSelector: selectorLabel,
+ DomainSigningPrivateKey: privateKeyPem,
+ },
+ }),
+ ),
+ );
+ } catch (error) {
+ // Only roll back an identity we just created; deleting a pre-existing one
+ // would silently break whatever was already configured against it.
+ if (didCreateIdentity) {
+ await removeSesEmailIdentity({ domain });
+ }
+
+ throw toSesAppError('configure DKIM signing for the sending domain', error);
+ }
+
+ return { didCreateIdentity };
+};
+
+export type SesIdentityRead =
+ | { kind: 'found'; verifiedForSending: boolean; dkimStatus: string | null; hasFailedDkim: boolean }
+ | { kind: 'absent' }
+ | { kind: 'unavailable'; reason: string };
+
+export const readSesEmailIdentity = async ({ domain }: { domain: string }): Promise => {
+ try {
+ const response = await withSesClient((client) =>
+ client.send(new GetEmailIdentityCommand({ EmailIdentity: domain })),
+ );
+
+ const parsed = ZEmailIdentityResponseSchema.safeParse(response);
+
+ if (!parsed.success) {
+ return { kind: 'unavailable', reason: 'Amazon SES returned an unreadable identity description' };
+ }
+
+ const dkimStatus = parsed.data.DkimAttributes?.Status ?? null;
+
+ return {
+ kind: 'found',
+ verifiedForSending: parsed.data.VerifiedForSendingStatus === true,
+ dkimStatus,
+ hasFailedDkim: dkimStatus === SES_DKIM_STATUS_FAILED,
+ };
+ } catch (error) {
+ if (isSesErrorName(error, 'NotFoundException')) {
+ return { kind: 'absent' };
+ }
+
+ logSesError('read the sending domain identity', error);
+
+ return { kind: 'unavailable', reason: describeAwsError(error).name };
+ }
+};
diff --git a/packages/lib/server-only/email-domain/types.ts b/packages/lib/server-only/email-domain/types.ts
new file mode 100644
index 0000000000..86f6dffb4b
--- /dev/null
+++ b/packages/lib/server-only/email-domain/types.ts
@@ -0,0 +1,17 @@
+/**
+ * A DNS record an administrator has to publish, in the shape consumed by the
+ * tRPC response schema and the records dialog.
+ */
+export type EmailDomainDnsRecord = {
+ name: string;
+ value: string;
+ type: string;
+};
+
+export type EmailDomainTransitionEvent =
+ | 'created'
+ | 'verified'
+ | 'downgraded'
+ | 'reregistered'
+ | 'deleted'
+ | 'takeover';
diff --git a/packages/lib/server-only/email-domain/verification-rate-limit.ts b/packages/lib/server-only/email-domain/verification-rate-limit.ts
new file mode 100644
index 0000000000..eb8d5a7110
--- /dev/null
+++ b/packages/lib/server-only/email-domain/verification-rate-limit.ts
@@ -0,0 +1,35 @@
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { createRateLimit } from '../rate-limit/rate-limit';
+import { EMAIL_DOMAIN_VERIFICATION_MAX_PER_HOUR } from './constants';
+
+const emailDomainVerificationRateLimit = createRateLimit({
+ action: 'email-domain.verify',
+ max: EMAIL_DOMAIN_VERIFICATION_MAX_PER_HOUR,
+ window: '1h',
+});
+
+/**
+ * Verification issues outbound DNS and SES traffic on the caller's behalf, and
+ * the organisation route fans a single click out across every domain the
+ * organisation owns. Without a per-organisation ceiling that is an amplification
+ * primitive against both our resolver and our SES quota.
+ *
+ * The hourly sync job stays well inside the limit: it only walks PENDING domains
+ * and pauses between batches.
+ */
+export const assertEmailDomainVerificationRateLimit = async (organisationId: string): Promise => {
+ const result = await emailDomainVerificationRateLimit.check({
+ ip: 'system:email-domain-verification',
+ identifier: organisationId,
+ });
+
+ if (result.isLimited) {
+ throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
+ message: 'Too many email domain verifications for this organisation.',
+ userMessage: 'Too many verification attempts. Please try again later.',
+ headers: {
+ 'Retry-After': String(Math.max(1, Math.ceil((result.reset.getTime() - Date.now()) / 1000))),
+ },
+ });
+ }
+};
diff --git a/packages/lib/server-only/email-domain/verification-state.ts b/packages/lib/server-only/email-domain/verification-state.ts
new file mode 100644
index 0000000000..e3e530e377
--- /dev/null
+++ b/packages/lib/server-only/email-domain/verification-state.ts
@@ -0,0 +1,47 @@
+import { CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE } from './constants';
+
+/**
+ * Consecutive authoritative negatives per row.
+ *
+ * The count lives in process memory because the persistence model has no column
+ * for it. Undercounting is the only failure mode: a restart, or a fleet spread
+ * across instances, resets the streak and simply delays a demotion. It can never
+ * cause a demotion that the evidence does not support, which is the direction
+ * that matters — an ACTIVE domain that is wrongly demoted stops sending mail for
+ * a real customer.
+ */
+const negativeStreaks = new Map();
+
+export type NegativeStreakSubject = {
+ emailDomainId: string;
+ selector: string;
+};
+
+/**
+ * The selector is part of the key so that reregistering a domain — which rotates
+ * the selector and every record the administrator has to republish — starts from
+ * a clean streak without an explicit reset.
+ */
+export const buildNegativeStreakKey = ({ emailDomainId, selector }: NegativeStreakSubject): string => {
+ return `${emailDomainId}:${selector}`;
+};
+
+export const readNegativeStreak = (key: string): number => {
+ return negativeStreaks.get(key) ?? 0;
+};
+
+export const recordDefinitiveNegative = (key: string): number => {
+ const streak = readNegativeStreak(key) + 1;
+
+ negativeStreaks.set(key, streak);
+
+ return streak;
+};
+
+export const clearNegativeStreak = (key: string): void => {
+ negativeStreaks.delete(key);
+};
+
+export const isDowngradeThresholdReached = (streak: number): boolean => {
+ return streak >= CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE;
+};
diff --git a/packages/lib/server-only/email-domain/verify-email-domain.test.ts b/packages/lib/server-only/email-domain/verify-email-domain.test.ts
new file mode 100644
index 0000000000..ca187f9fbe
--- /dev/null
+++ b/packages/lib/server-only/email-domain/verify-email-domain.test.ts
@@ -0,0 +1,516 @@
+import { EmailDomainStatus } from '@prisma/client';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+ process.env.NEXT_PRIVATE_SES_ACCESS_KEY_ID = 'cleanroom-access-key';
+ process.env.NEXT_PRIVATE_SES_SECRET_ACCESS_KEY = 'cleanroom-secret-key';
+ process.env.NEXT_PRIVATE_SES_REGION = 'us-east-1';
+});
+
+const mocks = vi.hoisted(() => ({
+ emailDomainFindUnique: vi.fn(),
+ emailDomainCreate: vi.fn(),
+ emailDomainUpdate: vi.fn(),
+ emailDomainDelete: vi.fn(),
+ rateLimitUpsert: vi.fn(),
+ sesSend: vi.fn(),
+ createEmailIdentityCommand: vi.fn(),
+ deleteEmailIdentityCommand: vi.fn(),
+ getEmailIdentityCommand: vi.fn(),
+ putDkimSigningAttributesCommand: vi.fn(),
+ resolveTxt: vi.fn(),
+ resolveSoa: vi.fn(),
+ logInfo: vi.fn(),
+ logWarn: vi.fn(),
+ logError: vi.fn(),
+}));
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ emailDomain: {
+ findUnique: mocks.emailDomainFindUnique,
+ create: mocks.emailDomainCreate,
+ update: mocks.emailDomainUpdate,
+ delete: mocks.emailDomainDelete,
+ },
+ rateLimit: {
+ upsert: mocks.rateLimitUpsert,
+ },
+ },
+}));
+
+// `SESv2Client` is a class because production code constructs it; an arrow
+// function cannot be constructed.
+vi.mock('@aws-sdk/client-sesv2', () => ({
+ SESv2Client: vi.fn(
+ class {
+ send = mocks.sesSend;
+ },
+ ),
+ CreateEmailIdentityCommand: mocks.createEmailIdentityCommand,
+ DeleteEmailIdentityCommand: mocks.deleteEmailIdentityCommand,
+ GetEmailIdentityCommand: mocks.getEmailIdentityCommand,
+ PutEmailIdentityDkimSigningAttributesCommand: mocks.putDkimSigningAttributesCommand,
+}));
+
+vi.mock('node:dns/promises', () => ({
+ resolveTxt: mocks.resolveTxt,
+ resolveSoa: mocks.resolveSoa,
+}));
+
+vi.mock('../../utils/logger', () => ({
+ logger: {
+ info: mocks.logInfo,
+ warn: mocks.logWarn,
+ error: mocks.logError,
+ },
+}));
+
+import { AppErrorCode } from '../../errors/app-error';
+import { buildOwnershipChallengeRecordValue, deriveOwnershipChallengeToken } from './ownership-challenge';
+import { verifyEmailDomain } from './verify-email-domain';
+
+const ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+const DOMAIN = 'example.com';
+const SELECTOR = 'crove-verifytest1._domainkey';
+const CHALLENGE_HOST = `_crove-verify.${DOMAIN}`;
+const DKIM_HOST = `${SELECTOR}.${DOMAIN}`;
+const ORGANISATION_ID = 'org_cleanroom_verify';
+const PUBLIC_KEY = `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA${'A'.repeat(300)}`;
+const UNRELATED_PUBLIC_KEY = `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA${'B'.repeat(300)}`;
+const SPF_RECORD = 'v=spf1 include:amazonses.com -all';
+
+const SES_IDENTITY_HEALTHY = {
+ IdentityType: 'MANAGED_DOMAIN',
+ VerifiedForSendingStatus: true,
+ DkimAttributes: { Status: 'SUCCESS', SigningEnabled: true },
+};
+
+const buildRow = (overrides: Record = {}) => ({
+ id: 'email_domain_verify',
+ domain: DOMAIN,
+ selector: SELECTOR,
+ publicKey: PUBLIC_KEY,
+ status: EmailDomainStatus.PENDING,
+ organisationId: ORGANISATION_ID,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ updatedAt: new Date('2026-01-01T00:00:00.000Z'),
+ lastVerifiedAt: null,
+ ...overrides,
+});
+
+const expectedChallengeValue = (emailDomainId: string): string =>
+ buildOwnershipChallengeRecordValue(
+ deriveOwnershipChallengeToken({ emailDomainId, selector: SELECTOR, domain: DOMAIN }, ENCRYPTION_KEY),
+ );
+
+const dnsError = (code: string): Error => Object.assign(new Error(`DNS failure ${code}`), { code });
+
+const sesError = (name: string, httpStatusCode?: number): Error =>
+ Object.assign(new Error(name), {
+ name,
+ $metadata: httpStatusCode === undefined ? undefined : { httpStatusCode, requestId: 'aws-request-id' },
+ });
+
+type DnsStub = Record;
+
+const stubTxt = (answers: DnsStub) => {
+ mocks.resolveTxt.mockImplementation((name: string) => {
+ const answer = answers[name] ?? dnsError('ENOTFOUND');
+
+ if (answer instanceof Error) {
+ return Promise.reject(answer);
+ }
+
+ return Promise.resolve(answer);
+ });
+};
+
+const stubHealthyDns = (emailDomainId: string, publishedPublicKey = PUBLIC_KEY) => {
+ stubTxt({
+ [CHALLENGE_HOST]: [[expectedChallengeValue(emailDomainId)]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; p=${publishedPublicKey}`]],
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+};
+
+const stubMissingRequiredRecords = () => {
+ stubTxt({
+ [CHALLENGE_HOST]: dnsError('ENOTFOUND'),
+ [DKIM_HOST]: dnsError('ENOTFOUND'),
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+};
+
+const stubRow = (emailDomainId: string, status: EmailDomainStatus = EmailDomainStatus.PENDING) => {
+ mocks.emailDomainFindUnique.mockResolvedValue(buildRow({ id: emailDomainId, status }));
+};
+
+const expectActiveDomainSurvivesDnsFailure = async (emailDomainId: string, failure: Error) => {
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubTxt({
+ [CHALLENGE_HOST]: failure,
+ [DKIM_HOST]: failure,
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ expect(result.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ expect(mocks.logWarn).toHaveBeenCalledWith(
+ expect.objectContaining({ msg: 'email_domain_verification_inconclusive' }),
+ );
+};
+
+describe('verifyEmailDomain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.emailDomainFindUnique.mockResolvedValue(null);
+ mocks.rateLimitUpsert.mockResolvedValue({ count: 1 });
+ mocks.sesSend.mockResolvedValue(SES_IDENTITY_HEALTHY);
+ mocks.emailDomainUpdate.mockResolvedValue(buildRow());
+ mocks.resolveSoa.mockResolvedValue({
+ nsname: 'ns1.example.com',
+ hostmaster: 'hostmaster.example.com',
+ serial: 1,
+ refresh: 3600,
+ retry: 600,
+ expire: 604800,
+ minttl: 60,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('activates a domain whose challenge TXT matches and whose DKIM key matches exactly', async () => {
+ const emailDomainId = 'email_domain_verify_activates';
+
+ stubRow(emailDomainId);
+ stubHealthyDns(emailDomainId);
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(true);
+ expect(result.status).toBe(EmailDomainStatus.ACTIVE);
+
+ expect(mocks.getEmailIdentityCommand).toHaveBeenCalledWith({ EmailIdentity: DOMAIN });
+
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledWith({
+ where: { id: emailDomainId },
+ data: { status: EmailDomainStatus.ACTIVE, lastVerifiedAt: expect.any(Date) },
+ });
+
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({ event: 'verified', organisationId: ORGANISATION_ID, domain: DOMAIN }),
+ );
+ });
+
+ it('accepts records that DNS returned split across several character-strings', async () => {
+ const emailDomainId = 'email_domain_verify_split_txt';
+ const challengeValue = expectedChallengeValue(emailDomainId);
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[challengeValue.slice(0, 20), challengeValue.slice(20)]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; p=${PUBLIC_KEY.slice(0, 200)}`, PUBLIC_KEY.slice(200)]],
+ [DOMAIN]: [['v=spf1 ', 'include:amazonses.com -all']],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(true);
+ });
+
+ it('fails when the published DKIM record belongs to a different key', async () => {
+ const emailDomainId = 'email_domain_verify_wrong_key';
+
+ stubRow(emailDomainId);
+ stubHealthyDns(emailDomainId, UNRELATED_PUBLIC_KEY);
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ expect(result.status).toBe(EmailDomainStatus.PENDING);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('fails when the published DKIM key merely extends ours', async () => {
+ const emailDomainId = 'email_domain_verify_superstring_key';
+
+ stubRow(emailDomainId);
+ stubHealthyDns(emailDomainId, `${PUBLIC_KEY}EXTRAMATERIAL`);
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ });
+
+ it('fails when the published DKIM key is a prefix of ours', async () => {
+ const emailDomainId = 'email_domain_verify_prefix_key';
+
+ stubRow(emailDomainId);
+ stubHealthyDns(emailDomainId, PUBLIC_KEY.slice(0, 120));
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ });
+
+ it('fails when a record looks like DKIM but does not carry our key', async () => {
+ const emailDomainId = 'email_domain_verify_dkim_shaped';
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[expectedChallengeValue(emailDomainId)]],
+ [DKIM_HOST]: [['v=DKIM1; k=rsa; h=sha256; t=s; p='], [SPF_RECORD]],
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ });
+
+ it('fails when the DKIM record is published in testing mode', async () => {
+ const emailDomainId = 'email_domain_verify_testing_flag';
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[expectedChallengeValue(emailDomainId)]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; t=y; p=${PUBLIC_KEY}`]],
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ });
+
+ it('fails when the ownership challenge value does not match exactly', async () => {
+ const emailDomainId = 'email_domain_verify_bad_challenge';
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[`${expectedChallengeValue(emailDomainId)}-tampered`]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; p=${PUBLIC_KEY}`]],
+ [DOMAIN]: [[SPF_RECORD]],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('leaves an ACTIVE domain ACTIVE when DNS times out', async () => {
+ await expectActiveDomainSurvivesDnsFailure('email_domain_verify_timeout', dnsError('ETIMEOUT'));
+ });
+
+ it('leaves an ACTIVE domain ACTIVE when DNS returns SERVFAIL', async () => {
+ await expectActiveDomainSurvivesDnsFailure('email_domain_verify_servfail', dnsError('ESERVFAIL'));
+ });
+
+ it('leaves an ACTIVE domain ACTIVE when the resolver cannot be reached', async () => {
+ await expectActiveDomainSurvivesDnsFailure('email_domain_verify_refused', dnsError('ECONNREFUSED'));
+ });
+
+ it('leaves an ACTIVE domain ACTIVE when Amazon SES returns a 5xx', async () => {
+ const emailDomainId = 'email_domain_verify_ses_5xx';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubHealthyDns(emailDomainId);
+ mocks.sesSend.mockRejectedValue(sesError('InternalFailure', 503));
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ expect(result.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ expect(mocks.logError).toHaveBeenCalledWith(expect.objectContaining({ msg: 'email_domain_ses_error' }));
+ });
+
+ it('leaves an ACTIVE domain ACTIVE when Amazon SES throttles the identity read', async () => {
+ const emailDomainId = 'email_domain_verify_ses_throttled';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubHealthyDns(emailDomainId);
+ mocks.sesSend.mockRejectedValue(sesError('TooManyRequestsException', 429));
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(false);
+ expect(result.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('downgrades an ACTIVE domain only on the third consecutive definitive negative', async () => {
+ const emailDomainId = 'email_domain_verify_three_strikes';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubMissingRequiredRecords();
+
+ const first = await verifyEmailDomain(emailDomainId);
+ const second = await verifyEmailDomain(emailDomainId);
+
+ expect(first.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(second.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+
+ const third = await verifyEmailDomain(emailDomainId);
+
+ expect(third.isVerified).toBe(false);
+ expect(third.status).toBe(EmailDomainStatus.PENDING);
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledWith({
+ where: { id: emailDomainId },
+ data: { status: EmailDomainStatus.PENDING },
+ });
+ expect(mocks.logInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ event: 'downgraded',
+ previousStatus: EmailDomainStatus.ACTIVE,
+ nextStatus: EmailDomainStatus.PENDING,
+ }),
+ );
+ });
+
+ it('does not count an authoritative negative when the resolver cannot confirm the zone', async () => {
+ const emailDomainId = 'email_domain_verify_broken_resolver';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubMissingRequiredRecords();
+ mocks.resolveSoa.mockRejectedValue(dnsError('ESERVFAIL'));
+
+ for (let attempt = 0; attempt < 5; attempt++) {
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.status).toBe(EmailDomainStatus.ACTIVE);
+ }
+
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('resets the negative streak once a verification succeeds again', async () => {
+ const emailDomainId = 'email_domain_verify_streak_reset';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubMissingRequiredRecords();
+
+ await verifyEmailDomain(emailDomainId);
+ await verifyEmailDomain(emailDomainId);
+
+ stubHealthyDns(emailDomainId);
+
+ const recovered = await verifyEmailDomain(emailDomainId);
+
+ expect(recovered.isVerified).toBe(true);
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledTimes(1);
+
+ stubMissingRequiredRecords();
+
+ const afterRecovery = await verifyEmailDomain(emailDomainId);
+
+ expect(afterRecovery.status).toBe(EmailDomainStatus.ACTIVE);
+ expect(mocks.emailDomainUpdate).toHaveBeenCalledTimes(1);
+ });
+
+ it('never downgrades a PENDING domain, whatever DNS says', async () => {
+ const emailDomainId = 'email_domain_verify_pending_negative';
+
+ stubRow(emailDomainId);
+ stubMissingRequiredRecords();
+
+ for (let attempt = 0; attempt < 4; attempt++) {
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.status).toBe(EmailDomainStatus.PENDING);
+ }
+
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+ });
+
+ it('activates a domain without an SPF record but warns about it', async () => {
+ const emailDomainId = 'email_domain_verify_no_spf';
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[expectedChallengeValue(emailDomainId)]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; p=${PUBLIC_KEY}`]],
+ [DOMAIN]: [['v=spf1 -all']],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(true);
+ expect(mocks.logWarn).toHaveBeenCalledWith(expect.objectContaining({ msg: 'email_domain_missing_spf_record' }));
+ });
+
+ it('does not read a disauthorising SPF mechanism as authorising Amazon SES', async () => {
+ const emailDomainId = 'email_domain_verify_negative_spf';
+
+ stubRow(emailDomainId);
+ stubTxt({
+ [CHALLENGE_HOST]: [[expectedChallengeValue(emailDomainId)]],
+ [DKIM_HOST]: [[`v=DKIM1; k=rsa; p=${PUBLIC_KEY}`]],
+ [DOMAIN]: [['v=spf1 -include:amazonses.com -all']],
+ });
+
+ const result = await verifyEmailDomain(emailDomainId);
+
+ expect(result.isVerified).toBe(true);
+ expect(mocks.logWarn).toHaveBeenCalledWith(expect.objectContaining({ msg: 'email_domain_missing_spf_record' }));
+ });
+
+ it('downgrades when Amazon SES no longer holds the identity', async () => {
+ const emailDomainId = 'email_domain_verify_ses_gone';
+
+ stubRow(emailDomainId, EmailDomainStatus.ACTIVE);
+ stubHealthyDns(emailDomainId);
+ mocks.sesSend.mockRejectedValue(sesError('NotFoundException', 404));
+
+ await verifyEmailDomain(emailDomainId);
+ await verifyEmailDomain(emailDomainId);
+
+ expect(mocks.emailDomainUpdate).not.toHaveBeenCalled();
+
+ const third = await verifyEmailDomain(emailDomainId);
+
+ expect(third.status).toBe(EmailDomainStatus.PENDING);
+ });
+
+ it('throws NOT_FOUND for an unknown id', async () => {
+ await expect(verifyEmailDomain('email_domain_missing')).rejects.toMatchObject({
+ code: AppErrorCode.NOT_FOUND,
+ });
+ });
+
+ it('throws NOT_SETUP and touches nothing when Amazon SES is unconfigured', async () => {
+ vi.stubEnv('NEXT_PRIVATE_SES_ACCESS_KEY_ID', '');
+
+ await expect(verifyEmailDomain('email_domain_no_ses')).rejects.toMatchObject({
+ code: AppErrorCode.NOT_SETUP,
+ });
+
+ expect(mocks.emailDomainFindUnique).not.toHaveBeenCalled();
+ expect(mocks.resolveTxt).not.toHaveBeenCalled();
+ expect(mocks.sesSend).not.toHaveBeenCalled();
+ });
+
+ it('rate limits verification per organisation', async () => {
+ const emailDomainId = 'email_domain_verify_rate_limited';
+
+ stubRow(emailDomainId);
+ mocks.rateLimitUpsert.mockResolvedValue({ count: 9999 });
+
+ await expect(verifyEmailDomain(emailDomainId)).rejects.toMatchObject({
+ code: AppErrorCode.TOO_MANY_REQUESTS,
+ });
+
+ expect(mocks.resolveTxt).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/server-only/email-domain/verify-email-domain.ts b/packages/lib/server-only/email-domain/verify-email-domain.ts
index 3a6615084c..6982434658 100644
--- a/packages/lib/server-only/email-domain/verify-email-domain.ts
+++ b/packages/lib/server-only/email-domain/verify-email-domain.ts
@@ -1,15 +1,92 @@
-import dns from 'node:dns/promises';
-import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { EmailDomainStatus } from '@prisma/client';
-import { getSesClient } from './create-email-domain';
+import { AppError, AppErrorCode } from '../../errors/app-error';
+import { logger } from '../../utils/logger';
+import { logEmailDomainTransition } from './audit';
+import { CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE } from './constants';
+import type { DomainDnsVerificationResult } from './domain-verification';
+import { checkDomainDnsConfiguration } from './domain-verification';
+import { assertEmailDomainEncryptionKey } from './key-material';
+import { buildOwnershipChallengeRecordValue, deriveOwnershipChallengeToken } from './ownership-challenge';
+import { assertSesServiceConfigured } from './ses-client';
+import type { SesIdentityRead } from './ses-identity';
+import { readSesEmailIdentity } from './ses-identity';
+import { assertEmailDomainVerificationRateLimit } from './verification-rate-limit';
+import {
+ buildNegativeStreakKey,
+ clearNegativeStreak,
+ isDowngradeThresholdReached,
+ recordDefinitiveNegative,
+} from './verification-state';
-export const verifyEmailDomain = async (emailDomainId: string) => {
+export type VerifyEmailDomainResult = {
+ isVerified: boolean;
+ status: EmailDomainStatus;
+ reason: string;
+};
+
+type SesVeto = { kind: 'none' } | { kind: 'definitive'; reason: string } | { kind: 'inconclusive'; reason: string };
+
+/**
+ * SES is consulted as a veto, not as the source of truth.
+ *
+ * DNS is what actually decides whether a recipient's mail server will accept our
+ * signatures, and SES only re-reads the same DNS on its own schedule. SES still
+ * gets a vote on the two things DNS cannot tell us: the identity was removed, or
+ * SES has definitively given up on signing for it. A pending or temporary SES
+ * state is not evidence against the domain.
+ */
+const classifySesIdentity = (identity: SesIdentityRead): SesVeto => {
+ if (identity.kind === 'unavailable') {
+ return { kind: 'inconclusive', reason: `Amazon SES could not be reached (${identity.reason})` };
+ }
+
+ if (identity.kind === 'absent') {
+ return { kind: 'definitive', reason: 'Amazon SES holds no identity for this domain' };
+ }
+
+ if (identity.hasFailedDkim) {
+ return { kind: 'definitive', reason: 'Amazon SES reports DKIM signing as failed for this domain' };
+ }
+
+ return { kind: 'none' };
+};
+
+const describeDefinitiveNegative = (dnsResult: DomainDnsVerificationResult, sesVeto: SesVeto): string => {
+ if (dnsResult.kind === 'unsatisfied') {
+ return dnsResult.reason;
+ }
+
+ if (sesVeto.kind === 'definitive') {
+ return sesVeto.reason;
+ }
+
+ return 'The domain is not correctly configured';
+};
+
+/**
+ * Read DNS (and SES) for a domain and move it between PENDING and ACTIVE (F3).
+ *
+ * Called positionally with a single id by both the organisation route and the
+ * hourly sync job.
+ */
+export const verifyEmailDomain = async (emailDomainId: string): Promise => {
+ assertSesServiceConfigured();
+
+ const encryptionKey = assertEmailDomainEncryptionKey();
+
+ // The private key is deliberately not selected: verification only needs the
+ // public half, and a secret that is never loaded cannot be logged.
const emailDomain = await prisma.emailDomain.findUnique({
- where: {
- id: emailDomainId,
+ where: { id: emailDomainId },
+ select: {
+ id: true,
+ domain: true,
+ selector: true,
+ publicKey: true,
+ status: true,
+ organisationId: true,
},
});
@@ -19,53 +96,142 @@ export const verifyEmailDomain = async (emailDomainId: string) => {
});
}
- let isVerified = false;
+ await assertEmailDomainVerificationRateLimit(emailDomain.organisationId);
- // 1. Try verification via AWS SES if client is configured
- const sesClient = getSesClient();
+ const ownershipChallengeValue = buildOwnershipChallengeRecordValue(
+ deriveOwnershipChallengeToken(
+ {
+ emailDomainId: emailDomain.id,
+ selector: emailDomain.selector,
+ domain: emailDomain.domain,
+ },
+ encryptionKey,
+ ),
+ );
- if (sesClient) {
- try {
- const response = await sesClient.send(
- new GetEmailIdentityCommand({
- EmailIdentity: emailDomain.domain,
- }),
- );
+ const [dnsResult, sesVeto] = await Promise.all([
+ checkDomainDnsConfiguration({
+ domain: emailDomain.domain,
+ selector: emailDomain.selector,
+ publicKey: emailDomain.publicKey,
+ ownershipChallengeValue,
+ }),
+ readSesEmailIdentity({ domain: emailDomain.domain }).then(classifySesIdentity),
+ ]);
- if (response.VerificationStatus === 'SUCCESS' || response.DkimAttributes?.Status === 'SUCCESS') {
- isVerified = true;
- }
- } catch (err) {
- console.warn(`[Email Domain] SES verification check for ${emailDomain.domain} failed:`, err);
- }
+ const streakKey = buildNegativeStreakKey({
+ emailDomainId: emailDomain.id,
+ selector: emailDomain.selector,
+ });
+
+ if (dnsResult.kind === 'inconclusive') {
+ logger.warn({
+ msg: 'email_domain_verification_inconclusive',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ reason: dnsResult.reason,
+ });
+
+ return { isVerified: false, status: emailDomain.status, reason: dnsResult.reason };
}
- // 2. Direct DNS TXT record check as fallback / universal verification
- if (!isVerified && emailDomain.selector) {
- try {
- const txtRecords = await dns.resolveTxt(emailDomain.selector);
- const flattenedTxt = txtRecords.map((chunk) => chunk.join('')).join('');
-
- if (flattenedTxt.includes('v=DKIM1') || (emailDomain.publicKey && flattenedTxt.includes(emailDomain.publicKey.slice(0, 32)))) {
- isVerified = true;
- }
- } catch (dnsErr) {
- // DNS record might not be propagated yet
+ if (sesVeto.kind === 'inconclusive') {
+ logger.warn({
+ msg: 'email_domain_verification_inconclusive',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ reason: sesVeto.reason,
+ });
+
+ return { isVerified: false, status: emailDomain.status, reason: sesVeto.reason };
+ }
+
+ if (dnsResult.kind === 'satisfied' && sesVeto.kind === 'none') {
+ clearNegativeStreak(streakKey);
+
+ await prisma.emailDomain.update({
+ where: { id: emailDomain.id },
+ data: {
+ status: EmailDomainStatus.ACTIVE,
+ lastVerifiedAt: new Date(),
+ },
+ });
+
+ if (!dnsResult.hasSpfRecord) {
+ logger.warn({
+ msg: 'email_domain_missing_spf_record',
+ emailDomainId: emailDomain.id,
+ domain: emailDomain.domain,
+ });
}
+
+ logEmailDomainTransition({
+ event: 'verified',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ previousStatus: emailDomain.status,
+ nextStatus: EmailDomainStatus.ACTIVE,
+ reason: 'Ownership challenge and DKIM public key both published correctly',
+ });
+
+ return {
+ isVerified: true,
+ status: EmailDomainStatus.ACTIVE,
+ reason: 'Ownership challenge and DKIM public key both published correctly',
+ };
}
- const updatedEmailDomain = await prisma.emailDomain.update({
- where: {
- id: emailDomainId,
- },
+ const negativeReason = describeDefinitiveNegative(dnsResult, sesVeto);
+
+ // A PENDING domain has nothing to lose, so the demotion machinery is only
+ // engaged for domains that are currently trusted to send.
+ if (emailDomain.status !== EmailDomainStatus.ACTIVE) {
+ clearNegativeStreak(streakKey);
+
+ return { isVerified: false, status: emailDomain.status, reason: negativeReason };
+ }
+
+ const negativeStreak = recordDefinitiveNegative(streakKey);
+
+ if (!isDowngradeThresholdReached(negativeStreak)) {
+ logger.warn({
+ msg: 'email_domain_definitive_negative',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ negativeStreak,
+ requiredStreak: CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE,
+ reason: negativeReason,
+ });
+
+ return {
+ isVerified: false,
+ status: EmailDomainStatus.ACTIVE,
+ reason: `${negativeReason} (negative ${negativeStreak}/${CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE})`,
+ };
+ }
+
+ clearNegativeStreak(streakKey);
+
+ await prisma.emailDomain.update({
+ where: { id: emailDomain.id },
data: {
- status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
- lastVerifiedAt: new Date(),
+ status: EmailDomainStatus.PENDING,
},
});
- return {
- emailDomain: updatedEmailDomain,
- isVerified,
- };
+ logEmailDomainTransition({
+ event: 'downgraded',
+ emailDomainId: emailDomain.id,
+ organisationId: emailDomain.organisationId,
+ domain: emailDomain.domain,
+ previousStatus: EmailDomainStatus.ACTIVE,
+ nextStatus: EmailDomainStatus.PENDING,
+ reason: `${CONSECUTIVE_NEGATIVES_BEFORE_DOWNGRADE} consecutive authoritative negatives: ${negativeReason}`,
+ });
+
+ return { isVerified: false, status: EmailDomainStatus.PENDING, reason: negativeReason };
};
diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts
index c9242834cf..9c6fd4445d 100644
--- a/packages/lib/server-only/email/get-email-context.ts
+++ b/packages/lib/server-only/email/get-email-context.ts
@@ -12,7 +12,7 @@ import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSetti
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
-import { IS_BILLING_ENABLED } from '../../constants/app';
+import { IS_BILLING_ENABLED, IS_EMAIL_DOMAINS_ENABLED } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
@@ -306,7 +306,10 @@ const getAllowedEmails = (
organisationClaim: OrganisationClaim;
},
) => {
- if (!organisation.organisationClaim.flags.emailDomains) {
+ // The per-organisation claim flag is upstream's; the instance flag is ours.
+ // Both must allow custom senders, so an operator can switch the feature off
+ // with CROVE_FEATURE_EMAIL_DOMAINS=false instead of editing the database.
+ if (!IS_EMAIL_DOMAINS_ENABLED() || !organisation.organisationClaim.flags.emailDomains) {
return [];
}
diff --git a/packages/lib/server-only/organisation/sso/link-audit.ts b/packages/lib/server-only/organisation/sso/link-audit.ts
new file mode 100644
index 0000000000..fc6ab53f0f
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-audit.ts
@@ -0,0 +1,38 @@
+import { prisma } from '@documenso/prisma';
+import { UserSecurityAuditLogType } from '@prisma/client';
+
+import type { RequestMetadata } from '../../../universal/extract-request-metadata';
+
+export type WriteOrganisationSsoLinkAuditLogOptions = {
+ userId: number;
+ /**
+ * Absent for events that happen outside a user request (the confirmation
+ * email is issued from the OIDC callback, whose signature carries no request
+ * metadata).
+ */
+ requestMeta?: RequestMetadata;
+};
+
+/**
+ * Records an organisation SSO link lifecycle event on the user's security audit
+ * log: confirmation issued, link completed, link refused.
+ *
+ * All three share `ORGANISATION_SSO_LINK` because `UserSecurityAuditLog` has no
+ * reason column and `UserSecurityAuditLogType` has no refusal-specific value;
+ * the precise outcome goes to the structured application log instead. Only the
+ * user and the request metadata are persisted — never a token value and never
+ * OAuth material.
+ */
+export const writeOrganisationSsoLinkAuditLog = async ({
+ userId,
+ requestMeta,
+}: WriteOrganisationSsoLinkAuditLogOptions) => {
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId,
+ ipAddress: requestMeta?.ipAddress,
+ userAgent: requestMeta?.userAgent,
+ type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
+ },
+ });
+};
diff --git a/packages/lib/server-only/organisation/sso/link-organisation-account.test.ts b/packages/lib/server-only/organisation/sso/link-organisation-account.test.ts
new file mode 100644
index 0000000000..7e0497664f
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-organisation-account.test.ts
@@ -0,0 +1,574 @@
+import { OrganisationMemberRole, UserSecurityAuditLogType } from '@prisma/client';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ // The link material is encrypted with the repository symmetric helper, so the
+ // key has to exist before `constants/crypto` is first evaluated.
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+
+ return {
+ verificationTokenFindFirst: vi.fn(),
+ verificationTokenUpdateMany: vi.fn(),
+ verificationTokenDelete: vi.fn(),
+ verificationTokenDeleteMany: vi.fn(),
+ userFindFirst: vi.fn(),
+ userUpdate: vi.fn(),
+ organisationFindFirst: vi.fn(),
+ organisationUpdate: vi.fn(),
+ organisationMemberFindFirst: vi.fn(),
+ accountFindFirst: vi.fn(),
+ accountUpsert: vi.fn(),
+ txUserUpdateMany: vi.fn(),
+ transaction: vi.fn(),
+ auditLogCreate: vi.fn(),
+ addUserToOrganisation: vi.fn(),
+ };
+});
+
+const transactionClient = {
+ account: { upsert: mocks.accountUpsert },
+ user: { updateMany: mocks.txUserUpdateMany },
+};
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ verificationToken: {
+ findFirst: mocks.verificationTokenFindFirst,
+ updateMany: mocks.verificationTokenUpdateMany,
+ delete: mocks.verificationTokenDelete,
+ deleteMany: mocks.verificationTokenDeleteMany,
+ },
+ user: {
+ findFirst: mocks.userFindFirst,
+ update: mocks.userUpdate,
+ },
+ organisation: {
+ findFirst: mocks.organisationFindFirst,
+ update: mocks.organisationUpdate,
+ },
+ organisationMember: {
+ findFirst: mocks.organisationMemberFindFirst,
+ },
+ account: {
+ findFirst: mocks.accountFindFirst,
+ },
+ userSecurityAuditLog: {
+ create: mocks.auditLogCreate,
+ },
+ $transaction: mocks.transaction,
+ },
+}));
+
+vi.mock('../../../utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('../accept-organisation-invitation', () => ({
+ addUserToOrganisation: mocks.addUserToOrganisation,
+}));
+
+import {
+ ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
+ ORGANISATION_USER_ACCOUNT_TYPE,
+} from '../../../constants/organisations';
+import { ONE_MINUTE } from '../../../constants/time';
+import { AppError, AppErrorCode } from '../../../errors/app-error';
+import { linkOrganisationAccount } from './link-organisation-account';
+import { encryptOrganisationAccountLinkOauthConfig } from './link-token';
+
+const TOKEN = 'confirmation-token-value';
+const TOKEN_SECONDARY_ID = 'vt_secondary_1';
+const VERIFICATION_TOKEN_ID = 7;
+const USER_ID = 42;
+const USER_EMAIL = 'alice@example.com';
+const ORGANISATION_ID = 'org_123';
+const PROVIDER_ACCOUNT_ID = 'oidc-subject-1';
+const ACCESS_TOKEN = 'sso-access-token-secret-value';
+const ID_TOKEN = 'sso-id-token-secret-value';
+const ACCESS_TOKEN_EXPIRES_AT = 1_900_000_000;
+
+const REQUEST_META = { ipAddress: '203.0.113.7', userAgent: 'vitest-agent' };
+
+type PortalRow = {
+ id: string;
+ enabled: boolean;
+ defaultOrganisationRole: OrganisationMemberRole;
+ autoProvisionUsers: boolean;
+ allowedDomains: string[];
+ allowPersonalOrganisations: boolean;
+};
+
+type GroupRow = {
+ id: string;
+ type: string;
+ organisationRole: OrganisationMemberRole;
+};
+
+type OrganisationRow = {
+ id: string;
+ name: string;
+ url: string;
+ groups: GroupRow[];
+ organisationAuthenticationPortal: PortalRow;
+};
+
+type VerificationTokenRow = {
+ id: number;
+ secondaryId: string;
+ identifier: string;
+ token: string;
+ completed: boolean;
+ expires: Date;
+ createdAt: Date;
+ metadata: unknown;
+ userId: number;
+};
+
+type UserRow = {
+ id: number;
+ email: string;
+ emailVerified: Date | null;
+};
+
+type AddUserToOrganisationArgs = {
+ userId: number;
+ organisationId: string;
+ organisationGroups: GroupRow[];
+ organisationMemberRole: OrganisationMemberRole;
+};
+
+type AccountUpsertArgs = {
+ where: { provider_providerAccountId: { provider: string; providerAccountId: string } };
+ create: Record;
+ update: Record;
+};
+
+type AuditLogCreateArgs = {
+ data: {
+ userId: number;
+ ipAddress: string | undefined;
+ userAgent: string | undefined;
+ type: UserSecurityAuditLogType;
+ };
+};
+
+const buildGroups = (): GroupRow[] => [
+ { id: 'group_admin', type: 'INTERNAL_ORGANISATION', organisationRole: OrganisationMemberRole.ADMIN },
+ { id: 'group_manager', type: 'INTERNAL_ORGANISATION', organisationRole: OrganisationMemberRole.MANAGER },
+ { id: 'group_member', type: 'INTERNAL_ORGANISATION', organisationRole: OrganisationMemberRole.MEMBER },
+];
+
+const buildPortal = (overrides: Partial = {}): PortalRow => ({
+ id: 'portal_1',
+ enabled: true,
+ defaultOrganisationRole: OrganisationMemberRole.MANAGER,
+ autoProvisionUsers: true,
+ allowedDomains: ['example.com'],
+ allowPersonalOrganisations: false,
+ ...overrides,
+});
+
+const buildOrganisation = (portalOverrides: Partial = {}): OrganisationRow => ({
+ id: ORGANISATION_ID,
+ name: 'Example Organisation',
+ url: 'example',
+ groups: buildGroups(),
+ organisationAuthenticationPortal: buildPortal(portalOverrides),
+});
+
+const buildMetadata = (overrides: Record = {}) => ({
+ type: 'link',
+ userId: USER_ID,
+ organisationId: ORGANISATION_ID,
+ oauthConfig: encryptOrganisationAccountLinkOauthConfig({
+ accessToken: ACCESS_TOKEN,
+ idToken: ID_TOKEN,
+ providerAccountId: PROVIDER_ACCOUNT_ID,
+ expiresAt: ACCESS_TOKEN_EXPIRES_AT,
+ }),
+ ...overrides,
+});
+
+const buildVerificationToken = (overrides: Partial = {}): VerificationTokenRow => ({
+ id: VERIFICATION_TOKEN_ID,
+ secondaryId: TOKEN_SECONDARY_ID,
+ identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
+ token: TOKEN,
+ completed: false,
+ expires: new Date(Date.now() + 30 * ONE_MINUTE),
+ createdAt: new Date(),
+ metadata: buildMetadata(),
+ userId: USER_ID,
+ ...overrides,
+});
+
+const buildUser = (overrides: Partial = {}): UserRow => ({
+ id: USER_ID,
+ email: USER_EMAIL,
+ emailVerified: null,
+ ...overrides,
+});
+
+const arrangeHappyPath = () => {
+ mocks.transaction.mockImplementation(async (runInTransaction: (tx: typeof transactionClient) => Promise) =>
+ runInTransaction(transactionClient),
+ );
+
+ mocks.verificationTokenFindFirst.mockResolvedValue(buildVerificationToken());
+ mocks.verificationTokenUpdateMany.mockResolvedValue({ count: 1 });
+ mocks.userFindFirst.mockResolvedValue(buildUser());
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation());
+ mocks.organisationMemberFindFirst.mockResolvedValue(null);
+ mocks.accountFindFirst.mockResolvedValue(null);
+ mocks.accountUpsert.mockResolvedValue({ id: 'account_1' });
+ mocks.txUserUpdateMany.mockResolvedValue({ count: 1 });
+ mocks.auditLogCreate.mockResolvedValue({ id: 1 });
+ mocks.addUserToOrganisation.mockResolvedValue(undefined);
+};
+
+/**
+ * Every refusal that has a resolved user must be audited. An unknown token is
+ * handled separately: with no user there is nothing to key an audit row on.
+ */
+const refusalScenarios: { name: string; arrange: () => void }[] = [
+ {
+ name: 'the token was already used',
+ arrange: () => mocks.verificationTokenFindFirst.mockResolvedValue(buildVerificationToken({ completed: true })),
+ },
+ {
+ name: 'the token expired',
+ arrange: () =>
+ mocks.verificationTokenFindFirst.mockResolvedValue(
+ buildVerificationToken({ expires: new Date(Date.now() - ONE_MINUTE) }),
+ ),
+ },
+ {
+ name: 'the token metadata is malformed',
+ arrange: () =>
+ mocks.verificationTokenFindFirst.mockResolvedValue(buildVerificationToken({ metadata: { type: 'link' } })),
+ },
+ {
+ name: 'the token metadata points at another user',
+ arrange: () =>
+ mocks.verificationTokenFindFirst.mockResolvedValue(
+ buildVerificationToken({ metadata: buildMetadata({ userId: USER_ID + 1 }) }),
+ ),
+ },
+ {
+ name: 'the user no longer exists',
+ arrange: () => mocks.userFindFirst.mockResolvedValue(null),
+ },
+ {
+ name: 'the organisation no longer exists',
+ arrange: () => mocks.organisationFindFirst.mockResolvedValue(null),
+ },
+ {
+ name: 'the portal was disabled',
+ arrange: () => mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ enabled: false })),
+ },
+ {
+ name: 'the email domain is no longer allowed',
+ arrange: () =>
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ allowedDomains: ['example.org'] })),
+ },
+ {
+ name: 'auto provisioning was disabled',
+ arrange: () => mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ autoProvisionUsers: false })),
+ },
+ {
+ name: 'the provider account belongs to somebody else',
+ arrange: () => mocks.accountFindFirst.mockResolvedValue({ id: 'account_1', userId: USER_ID + 1 }),
+ },
+ {
+ name: 'the token was redeemed concurrently',
+ arrange: () => mocks.verificationTokenUpdateMany.mockResolvedValue({ count: 0 }),
+ },
+];
+
+describe('linkOrganisationAccount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ arrangeHappyPath();
+ });
+
+ it('adds the user with the portal default role, persists the oidc account and completes the token', async () => {
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ expect(mocks.verificationTokenFindFirst).toHaveBeenCalledWith({
+ where: {
+ token: TOKEN,
+ identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
+ },
+ });
+
+ expect(mocks.verificationTokenUpdateMany).toHaveBeenCalledWith({
+ where: { id: VERIFICATION_TOKEN_ID, completed: false },
+ data: { completed: true },
+ });
+
+ const [addUserArgs] = mocks.addUserToOrganisation.mock.calls[0] as unknown as [AddUserToOrganisationArgs];
+
+ expect(addUserArgs).toEqual({
+ userId: USER_ID,
+ organisationId: ORGANISATION_ID,
+ organisationGroups: buildGroups(),
+ organisationMemberRole: OrganisationMemberRole.MANAGER,
+ // The confirmation email the user just clicked makes the "member joined"
+ // notification redundant, and it would run after the token is consumed.
+ bypassEmail: true,
+ });
+
+ const [accountUpsertArgs] = mocks.accountUpsert.mock.calls[0] as unknown as [AccountUpsertArgs];
+
+ expect(accountUpsertArgs.where.provider_providerAccountId).toEqual({
+ provider: ORGANISATION_ID,
+ providerAccountId: PROVIDER_ACCOUNT_ID,
+ });
+ expect(accountUpsertArgs.create).toMatchObject({
+ userId: USER_ID,
+ type: ORGANISATION_USER_ACCOUNT_TYPE,
+ provider: ORGANISATION_ID,
+ providerAccountId: PROVIDER_ACCOUNT_ID,
+ access_token: ACCESS_TOKEN,
+ id_token: ID_TOKEN,
+ expires_at: ACCESS_TOKEN_EXPIRES_AT,
+ });
+
+ expect(mocks.auditLogCreate).toHaveBeenCalledWith({
+ data: {
+ userId: USER_ID,
+ ipAddress: REQUEST_META.ipAddress,
+ userAgent: REQUEST_META.userAgent,
+ type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
+ },
+ });
+ });
+
+ it('validates before consuming: an expired token is left untouched', async () => {
+ mocks.verificationTokenFindFirst.mockResolvedValue(
+ buildVerificationToken({ expires: new Date(Date.now() - ONE_MINUTE) }),
+ );
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.verificationTokenUpdateMany).not.toHaveBeenCalled();
+ expect(mocks.verificationTokenDelete).not.toHaveBeenCalled();
+ expect(mocks.verificationTokenDeleteMany).not.toHaveBeenCalled();
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.accountUpsert).not.toHaveBeenCalled();
+ });
+
+ it('validates before consuming: malformed metadata is left untouched', async () => {
+ mocks.verificationTokenFindFirst.mockResolvedValue(buildVerificationToken({ metadata: { type: 'link' } }));
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.verificationTokenUpdateMany).not.toHaveBeenCalled();
+ expect(mocks.verificationTokenDelete).not.toHaveBeenCalled();
+ expect(mocks.userFindFirst).not.toHaveBeenCalled();
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ });
+
+ it('refuses an already completed token without creating a second membership', async () => {
+ mocks.verificationTokenFindFirst.mockResolvedValue(buildVerificationToken({ completed: true }));
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.verificationTokenUpdateMany).not.toHaveBeenCalled();
+ expect(mocks.verificationTokenDelete).not.toHaveBeenCalled();
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.organisationMemberFindFirst).not.toHaveBeenCalled();
+ });
+
+ it('refuses an unknown token and leaves no audit trail for it', async () => {
+ mocks.verificationTokenFindFirst.mockResolvedValue(null);
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.userFindFirst).not.toHaveBeenCalled();
+ expect(mocks.auditLogCreate).not.toHaveBeenCalled();
+ });
+
+ it('refuses when the email domain is no longer permitted by the portal', async () => {
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ allowedDomains: ['example.org'] }));
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.auditLogCreate).toHaveBeenCalledOnce();
+ });
+
+ it('permits any domain when the portal does not restrict them', async () => {
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ allowedDomains: [] }));
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).resolves.toBeUndefined();
+
+ expect(mocks.addUserToOrganisation).toHaveBeenCalledOnce();
+ });
+
+ it('refuses when the portal has been disabled', async () => {
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ enabled: false }));
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toMatchObject({
+ code: AppErrorCode.INVALID_REQUEST,
+ });
+
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.verificationTokenUpdateMany).not.toHaveBeenCalled();
+ expect(mocks.auditLogCreate).toHaveBeenCalledOnce();
+ });
+
+ it('never writes a password while linking', async () => {
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ expect(mocks.userUpdate).not.toHaveBeenCalled();
+
+ const userWrites = JSON.stringify(mocks.txUserUpdateMany.mock.calls);
+
+ expect(userWrites).not.toContain('password');
+ expect(userWrites).toContain('emailVerified');
+ expect(JSON.stringify(mocks.accountUpsert.mock.calls)).not.toContain('"password"');
+ });
+
+ it('sets emailVerified when it is still null', async () => {
+ mocks.userFindFirst.mockResolvedValue(buildUser({ emailVerified: null }));
+
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ expect(mocks.txUserUpdateMany).toHaveBeenCalledWith({
+ where: { id: USER_ID, emailVerified: null },
+ data: { emailVerified: expect.any(Date) },
+ });
+ });
+
+ it('preserves an existing emailVerified timestamp', async () => {
+ mocks.userFindFirst.mockResolvedValue(buildUser({ emailVerified: new Date('2024-01-01T00:00:00.000Z') }));
+
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ // The write is guarded by `emailVerified: null`, so an account verified
+ // earlier is never re-stamped — and here it is skipped entirely.
+ expect(mocks.txUserUpdateMany).not.toHaveBeenCalled();
+ expect(mocks.userUpdate).not.toHaveBeenCalled();
+ expect(mocks.addUserToOrganisation).toHaveBeenCalledOnce();
+ });
+
+ it('clamps the granted role to the portal default and never grants ownership', async () => {
+ mocks.organisationFindFirst.mockResolvedValue(
+ buildOrganisation({ defaultOrganisationRole: OrganisationMemberRole.MEMBER }),
+ );
+
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ const [addUserArgs] = mocks.addUserToOrganisation.mock.calls[0] as unknown as [AddUserToOrganisationArgs];
+
+ expect(addUserArgs.organisationMemberRole).toBe(OrganisationMemberRole.MEMBER);
+ expect(addUserArgs.organisationMemberRole).not.toBe(OrganisationMemberRole.ADMIN);
+ expect(mocks.organisationUpdate).not.toHaveBeenCalled();
+ });
+
+ it('does not create a second membership for an existing member', async () => {
+ mocks.organisationMemberFindFirst.mockResolvedValue({ id: 'member_1' });
+
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.accountUpsert).toHaveBeenCalledOnce();
+ });
+
+ it('links an existing member even when auto provisioning is disabled', async () => {
+ mocks.organisationFindFirst.mockResolvedValue(buildOrganisation({ autoProvisionUsers: false }));
+ mocks.organisationMemberFindFirst.mockResolvedValue({ id: 'member_1' });
+
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+
+ expect(mocks.addUserToOrganisation).not.toHaveBeenCalled();
+ expect(mocks.accountUpsert).toHaveBeenCalledOnce();
+ });
+
+ it('releases the claim when provisioning fails and hides the underlying error', async () => {
+ mocks.addUserToOrganisation.mockRejectedValue(new Error('database exploded'));
+
+ let caughtError: unknown;
+
+ try {
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+ } catch (error) {
+ caughtError = error;
+ }
+
+ expect(caughtError).toBeInstanceOf(AppError);
+
+ const appError = AppError.parseError(caughtError);
+
+ expect(appError.code).toBe(AppErrorCode.UNKNOWN_ERROR);
+ expect(appError.message).not.toContain('database exploded');
+
+ expect(mocks.verificationTokenUpdateMany).toHaveBeenLastCalledWith({
+ where: { id: VERIFICATION_TOKEN_ID },
+ data: { completed: false },
+ });
+ });
+
+ describe('refusals', () => {
+ for (const scenario of refusalScenarios) {
+ it(`audits the refusal when ${scenario.name}`, async () => {
+ scenario.arrange();
+
+ await expect(linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META })).rejects.toBeInstanceOf(
+ AppError,
+ );
+
+ expect(mocks.auditLogCreate).toHaveBeenCalledOnce();
+
+ const [auditArgs] = mocks.auditLogCreate.mock.calls[0] as unknown as [AuditLogCreateArgs];
+
+ expect(auditArgs.data).toEqual({
+ userId: USER_ID,
+ ipAddress: REQUEST_META.ipAddress,
+ userAgent: REQUEST_META.userAgent,
+ type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
+ });
+ });
+ }
+
+ it('reports every refusal identically to the caller', async () => {
+ const reportedErrors = new Set();
+
+ for (const scenario of refusalScenarios) {
+ vi.clearAllMocks();
+ arrangeHappyPath();
+ scenario.arrange();
+
+ let caughtError: unknown;
+
+ try {
+ await linkOrganisationAccount({ token: TOKEN, requestMeta: REQUEST_META });
+ } catch (error) {
+ caughtError = error;
+ }
+
+ const appError = AppError.parseError(caughtError);
+
+ reportedErrors.add(`${appError.code}:${appError.message}:${appError.userMessage}`);
+ }
+
+ expect(reportedErrors.size).toBe(1);
+ });
+ });
+});
diff --git a/packages/lib/server-only/organisation/sso/link-organisation-account.ts b/packages/lib/server-only/organisation/sso/link-organisation-account.ts
index 7666301d51..1eda85e20e 100644
--- a/packages/lib/server-only/organisation/sso/link-organisation-account.ts
+++ b/packages/lib/server-only/organisation/sso/link-organisation-account.ts
@@ -1,141 +1,412 @@
-import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
+import { prisma } from '@documenso/prisma';
+
import {
ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
ORGANISATION_USER_ACCOUNT_TYPE,
-} from '@documenso/lib/constants/organisations';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
-import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
-import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
-import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
-import { prisma } from '@documenso/prisma';
-import { UserSecurityAuditLogType } from '@prisma/client';
+} from '../../../constants/organisations';
+import { AppError, AppErrorCode } from '../../../errors/app-error';
+import type { RequestMetadata } from '../../../universal/extract-request-metadata';
+import { logger } from '../../../utils/logger';
+import { addUserToOrganisation } from '../accept-organisation-invitation';
+import { writeOrganisationSsoLinkAuditLog } from './link-audit';
+import { isEmailDomainPermittedByPortal, resolveGrantedOrganisationRole } from './link-policy';
+import {
+ decryptOrganisationAccountLinkOauthConfig,
+ isOrganisationAccountLinkTokenExpired,
+ parseOrganisationAccountLinkMetadata,
+} from './link-token';
+
+/**
+ * Why a redemption was refused.
+ *
+ * Only ever written to the application log. The caller-facing error is uniform
+ * across every refusal so an unauthenticated caller cannot use the response to
+ * probe which links exist, which expired and which were rejected on policy.
+ */
+type OrganisationAccountLinkRefusalReason =
+ | 'token-unknown'
+ | 'token-already-used'
+ | 'token-expired'
+ | 'token-metadata-invalid'
+ | 'token-user-mismatch'
+ | 'user-missing'
+ | 'organisation-missing'
+ | 'portal-disabled'
+ | 'email-domain-not-allowed'
+ | 'auto-provisioning-disabled'
+ | 'provider-account-conflict'
+ | 'token-redeemed-concurrently';
+
+const LINK_REFUSAL_MESSAGE = 'Unable to link the organisation account';
+
+const LINK_REFUSAL_USER_MESSAGE =
+ 'This account link is no longer valid. Please sign in through your organisation again to request a new link.';
+
+type RefuseOrganisationAccountLinkOptions = {
+ reason: OrganisationAccountLinkRefusalReason;
+ requestMeta: RequestMetadata;
+ userId?: number;
+ organisationId?: string;
+ tokenSecondaryId?: string;
+};
+
+/**
+ * Records a refusal and rejects the redemption.
+ *
+ * Nothing is mutated before this is called, so an expired or malformed link is
+ * left in place: burning it would destroy the evidence needed to explain the
+ * failure and would let anyone who guesses a pending link invalidate it.
+ */
+const refuseOrganisationAccountLink = async ({
+ reason,
+ requestMeta,
+ userId,
+ organisationId,
+ tokenSecondaryId,
+}: RefuseOrganisationAccountLinkOptions): Promise => {
+ logger.warn({
+ msg: 'Organisation account link refused',
+ reason,
+ userId,
+ organisationId,
+ tokenSecondaryId,
+ });
+
+ // Refusals that happen before a user is resolved (an unknown token) cannot be
+ // audited: the audit row is keyed on a user.
+ if (userId !== undefined) {
+ await writeOrganisationSsoLinkAuditLog({ userId, requestMeta }).catch(() => {
+ // The user may themselves be gone, which is the reason for the refusal in
+ // the `user-missing` case. Losing the audit row must not mask it.
+ logger.error({
+ msg: 'Unable to write the organisation account link refusal audit log',
+ reason,
+ userId,
+ organisationId,
+ tokenSecondaryId,
+ });
+ });
+ }
+
+ throw new AppError(AppErrorCode.INVALID_REQUEST, {
+ message: LINK_REFUSAL_MESSAGE,
+ userMessage: LINK_REFUSAL_USER_MESSAGE,
+ });
+};
export type LinkOrganisationAccountOptions = {
token: string;
requestMeta: RequestMetadata;
};
-export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrganisationAccountOptions) => {
- // Delete the token since it contains sensitive single-use data.
- const verificationToken = await prisma.verificationToken.delete({
+/**
+ * Redeems an organisation SSO account link confirmation.
+ *
+ * Unauthenticated by design — possession of the emailed link is the
+ * authorisation — so everything the caller cannot be trusted to have checked is
+ * re-verified here: token state, portal configuration, allowed domains and the
+ * role that is granted. The caller rate limits; this function does not.
+ */
+export const linkOrganisationAccount = async ({
+ token,
+ requestMeta,
+}: LinkOrganisationAccountOptions): Promise => {
+ // ── Validation phase ────────────────────────────────────────────────────
+ const verificationToken = await prisma.verificationToken.findFirst({
where: {
token,
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
},
- include: {
- user: {
- select: {
- id: true,
- emailVerified: true,
- accounts: {
- select: {
- provider: true,
- providerAccountId: true,
- },
- },
- },
- },
- },
});
if (!verificationToken) {
- throw new AppError(AppErrorCode.INVALID_REQUEST, {
- message: 'Verification token not found, used or expired',
- });
+ return refuseOrganisationAccountLink({ reason: 'token-unknown', requestMeta });
}
+ const { id: verificationTokenId, secondaryId: tokenSecondaryId, userId: tokenUserId } = verificationToken;
+
if (verificationToken.completed) {
- throw new AppError('ALREADY_USED');
+ return refuseOrganisationAccountLink({
+ reason: 'token-already-used',
+ requestMeta,
+ userId: tokenUserId,
+ tokenSecondaryId,
+ });
}
- if (verificationToken.expires < new Date()) {
- throw new AppError(AppErrorCode.INVALID_REQUEST, {
- message: 'Verification token not found, used or expired',
+ if (isOrganisationAccountLinkTokenExpired(verificationToken.expires)) {
+ return refuseOrganisationAccountLink({
+ reason: 'token-expired',
+ requestMeta,
+ userId: tokenUserId,
+ tokenSecondaryId,
});
}
- const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
+ const metadata = parseOrganisationAccountLinkMetadata(verificationToken.metadata);
- if (!tokenMetadata.success) {
- console.error('Invalid token metadata', tokenMetadata.error);
+ if (!metadata) {
+ return refuseOrganisationAccountLink({
+ reason: 'token-metadata-invalid',
+ requestMeta,
+ userId: tokenUserId,
+ tokenSecondaryId,
+ });
+ }
- throw new AppError(AppErrorCode.INVALID_REQUEST, {
- message: 'Verification token not found, used or expired',
+ // The row and its metadata must agree on the user. A mismatch means the stored
+ // material was tampered with, so it is never acted on.
+ if (metadata.userId !== tokenUserId) {
+ return refuseOrganisationAccountLink({
+ reason: 'token-user-mismatch',
+ requestMeta,
+ userId: tokenUserId,
+ organisationId: metadata.organisationId,
+ tokenSecondaryId,
});
}
- const user = verificationToken.user;
+ const user = await prisma.user.findFirst({
+ where: {
+ id: tokenUserId,
+ },
+ select: {
+ id: true,
+ email: true,
+ emailVerified: true,
+ },
+ });
+
+ if (!user) {
+ return refuseOrganisationAccountLink({
+ reason: 'user-missing',
+ requestMeta,
+ userId: tokenUserId,
+ organisationId: metadata.organisationId,
+ tokenSecondaryId,
+ });
+ }
- const { clientOptions, organisation } = await getOrganisationAuthenticationPortalOptions({
- type: 'id',
- organisationId: tokenMetadata.data.organisationId,
+ const organisation = await prisma.organisation.findFirst({
+ where: {
+ id: metadata.organisationId,
+ },
+ include: {
+ groups: true,
+ organisationAuthenticationPortal: true,
+ },
});
- const organisationMember = await prisma.organisationMember.findFirst({
+ if (!organisation) {
+ return refuseOrganisationAccountLink({
+ reason: 'organisation-missing',
+ requestMeta,
+ userId: user.id,
+ organisationId: metadata.organisationId,
+ tokenSecondaryId,
+ });
+ }
+
+ const portal = organisation.organisationAuthenticationPortal;
+
+ if (!portal.enabled) {
+ return refuseOrganisationAccountLink({
+ reason: 'portal-disabled',
+ requestMeta,
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
+ }
+
+ // Defence in depth: the portal may have been reconfigured between issue and
+ // redemption, so the domain restriction is enforced again here and fails
+ // closed.
+ if (!isEmailDomainPermittedByPortal(user.email, portal.allowedDomains)) {
+ return refuseOrganisationAccountLink({
+ reason: 'email-domain-not-allowed',
+ requestMeta,
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
+ }
+
+ const oauthConfig = decryptOrganisationAccountLinkOauthConfig(metadata.oauthConfig);
+
+ const existingMembership = await prisma.organisationMember.findFirst({
where: {
userId: user.id,
- organisationId: tokenMetadata.data.organisationId,
+ organisationId: organisation.id,
+ },
+ select: {
+ id: true,
},
});
- const oauthConfig = tokenMetadata.data.oauthConfig;
+ // An organisation that switched auto-provisioning off after the link was
+ // issued must not gain a member through it. Existing members are unaffected.
+ if (!existingMembership && !portal.autoProvisionUsers) {
+ return refuseOrganisationAccountLink({
+ reason: 'auto-provisioning-disabled',
+ requestMeta,
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
+ }
- const userAlreadyLinked = user.accounts.find(
- (account) => account.provider === clientOptions.id && account.providerAccountId === oauthConfig.providerAccountId,
- );
+ const existingAccount = await prisma.account.findFirst({
+ where: {
+ provider: organisation.id,
+ providerAccountId: oauthConfig.providerAccountId,
+ },
+ select: {
+ id: true,
+ userId: true,
+ },
+ });
- if (organisationMember && userAlreadyLinked) {
- return;
+ // The identity provider subject is already bound to somebody else. Silently
+ // rebinding it would move that person's SSO sign-in onto this account, so the
+ // conflict is surfaced instead.
+ if (existingAccount && existingAccount.userId !== user.id) {
+ return refuseOrganisationAccountLink({
+ reason: 'provider-account-conflict',
+ requestMeta,
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
}
- // Link the user if not linked yet.
- if (!userAlreadyLinked) {
+ // The granted role is the portal's configured default, clamped to a known
+ // member role and never elevated. Ownership is never written by this flow.
+ const organisationMemberRole = resolveGrantedOrganisationRole(portal.defaultOrganisationRole);
+
+ // ── Mutation phase ──────────────────────────────────────────────────────
+ // Claiming the token with `completed: false` in the filter makes redemption
+ // atomic: a concurrent request matches zero rows and is rejected, which is how
+ // a link redeemed twice is kept from provisioning the same user twice.
+ const claimedTokens = await prisma.verificationToken.updateMany({
+ where: {
+ id: verificationTokenId,
+ completed: false,
+ },
+ data: {
+ completed: true,
+ },
+ });
+
+ if (claimedTokens.count === 0) {
+ return refuseOrganisationAccountLink({
+ reason: 'token-redeemed-concurrently',
+ requestMeta,
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
+ }
+
+ try {
+ if (!existingMembership) {
+ await addUserToOrganisation({
+ userId: user.id,
+ organisationId: organisation.id,
+ organisationGroups: organisation.groups,
+ organisationMemberRole,
+ // The user arrived by clicking a link we emailed seconds ago, so the
+ // "member joined" notification carries no new information and only adds
+ // a job that can fail after the token has already been consumed.
+ bypassEmail: true,
+ });
+ }
+
await prisma.$transaction(async (tx) => {
- await tx.account.create({
- data: {
+ // Persisting the OIDC account row is what makes the next SSO sign-in
+ // resolve straight to this user instead of issuing another confirmation.
+ await tx.account.upsert({
+ where: {
+ provider_providerAccountId: {
+ provider: organisation.id,
+ providerAccountId: oauthConfig.providerAccountId,
+ },
+ },
+ create: {
+ userId: user.id,
type: ORGANISATION_USER_ACCOUNT_TYPE,
- provider: clientOptions.id,
+ provider: organisation.id,
providerAccountId: oauthConfig.providerAccountId,
access_token: oauthConfig.accessToken,
+ id_token: oauthConfig.idToken,
expires_at: oauthConfig.expiresAt,
token_type: 'Bearer',
- id_token: oauthConfig.idToken,
- userId: user.id,
},
- });
-
- // Log link event.
- await tx.userSecurityAuditLog.create({
- data: {
- userId: user.id,
- ipAddress: requestMeta.ipAddress,
- userAgent: requestMeta.userAgent,
- type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
+ update: {
+ access_token: oauthConfig.accessToken,
+ id_token: oauthConfig.idToken,
+ expires_at: oauthConfig.expiresAt,
+ token_type: 'Bearer',
},
});
+ // Clicking a link delivered to this exact inbox is genuine proof of
+ // control, which is the only reason `emailVerified` may be set here. The
+ // `emailVerified: null` filter keeps an earlier verification timestamp
+ // intact even if the account was verified concurrently.
+ //
+ // The password is deliberately left alone for both flavours of link:
+ // converting an account to SSO-only is a separate, user-initiated action.
if (!user.emailVerified) {
- await tx.user.update({
+ await tx.user.updateMany({
where: {
id: user.id,
+ emailVerified: null,
},
data: {
emailVerified: new Date(),
- password: null,
},
});
}
});
- }
+ } catch (error) {
+ // Release the claim so a transient failure does not permanently burn a link
+ // the user still holds. Best effort — the token expires on its own anyway.
+ await prisma.verificationToken
+ .updateMany({
+ where: {
+ id: verificationTokenId,
+ },
+ data: {
+ completed: false,
+ },
+ })
+ .catch(() => undefined);
- // Add the user to the organisation if not in it yet.
- if (!organisationMember) {
- await addUserToOrganisation({
- organisationId: organisation.id,
+ logger.error({
+ msg: 'Organisation account link failed after the token was claimed',
userId: user.id,
- organisationGroups: organisation.groups,
- organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
- bypassEmail: true,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
+
+ if (error instanceof AppError) {
+ throw error;
+ }
+
+ // A raw driver error can embed the statement's arguments, which hold the
+ // decrypted OAuth material, so it never reaches the caller.
+ throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
+ message: 'Unable to complete the organisation account link',
});
}
+
+ await writeOrganisationSsoLinkAuditLog({ userId: user.id, requestMeta });
+
+ logger.info({
+ msg: 'Organisation account linked',
+ userId: user.id,
+ organisationId: organisation.id,
+ tokenSecondaryId,
+ });
};
diff --git a/packages/lib/server-only/organisation/sso/link-policy.test.ts b/packages/lib/server-only/organisation/sso/link-policy.test.ts
new file mode 100644
index 0000000000..9da5f6aaba
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-policy.test.ts
@@ -0,0 +1,54 @@
+import { OrganisationMemberRole } from '@prisma/client';
+import { describe, expect, it } from 'vitest';
+
+import { extractEmailDomain, isEmailDomainPermittedByPortal, resolveGrantedOrganisationRole } from './link-policy';
+
+describe('resolveGrantedOrganisationRole', () => {
+ it('grants exactly the configured member role', () => {
+ expect(resolveGrantedOrganisationRole(OrganisationMemberRole.MEMBER)).toBe(OrganisationMemberRole.MEMBER);
+ expect(resolveGrantedOrganisationRole(OrganisationMemberRole.MANAGER)).toBe(OrganisationMemberRole.MANAGER);
+ expect(resolveGrantedOrganisationRole(OrganisationMemberRole.ADMIN)).toBe(OrganisationMemberRole.ADMIN);
+ });
+
+ it('clamps anything that is not a member role down to the lowest role', () => {
+ expect(resolveGrantedOrganisationRole('OWNER')).toBe(OrganisationMemberRole.MEMBER);
+ expect(resolveGrantedOrganisationRole('')).toBe(OrganisationMemberRole.MEMBER);
+ expect(resolveGrantedOrganisationRole('manager')).toBe(OrganisationMemberRole.MEMBER);
+ });
+});
+
+describe('extractEmailDomain', () => {
+ it('returns the lowercased domain of an address', () => {
+ expect(extractEmailDomain('Alice@Example.com')).toBe('example.com');
+ });
+
+ it('returns null when there is no usable domain', () => {
+ expect(extractEmailDomain('alice')).toBeNull();
+ expect(extractEmailDomain('alice@')).toBeNull();
+ });
+});
+
+describe('isEmailDomainPermittedByPortal', () => {
+ it('permits any domain when the portal does not restrict them', () => {
+ expect(isEmailDomainPermittedByPortal('alice@example.com', [])).toBe(true);
+ expect(isEmailDomainPermittedByPortal('alice@example.com', [' '])).toBe(true);
+ });
+
+ it('permits a listed domain regardless of casing or a leading @', () => {
+ expect(isEmailDomainPermittedByPortal('alice@example.com', ['EXAMPLE.com'])).toBe(true);
+ expect(isEmailDomainPermittedByPortal('alice@example.com', ['@example.com'])).toBe(true);
+ });
+
+ it('refuses a domain that is not listed', () => {
+ expect(isEmailDomainPermittedByPortal('alice@other.com', ['example.com'])).toBe(false);
+ });
+
+ it('does not treat a subdomain as the listed parent domain', () => {
+ expect(isEmailDomainPermittedByPortal('alice@subsidiary.example.com', ['example.com'])).toBe(false);
+ expect(isEmailDomainPermittedByPortal('alice@subsidiary.example.com', ['subsidiary.example.com'])).toBe(true);
+ });
+
+ it('fails closed for an address without a domain', () => {
+ expect(isEmailDomainPermittedByPortal('alice', ['example.com'])).toBe(false);
+ });
+});
diff --git a/packages/lib/server-only/organisation/sso/link-policy.ts b/packages/lib/server-only/organisation/sso/link-policy.ts
new file mode 100644
index 0000000000..fe9a56fe52
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-policy.ts
@@ -0,0 +1,71 @@
+import { OrganisationMemberRole } from '@prisma/client';
+
+import { LOWEST_ORGANISATION_ROLE } from '../../../constants/organisations';
+
+/**
+ * The member roles an SSO-provisioned user may be given.
+ *
+ * Organisation ownership is deliberately absent: it is not a member role (it
+ * lives on `Organisation.ownerUserId`), and nothing in the SSO link flow writes
+ * to it.
+ */
+const PERMITTED_SSO_ORGANISATION_ROLES: OrganisationMemberRole[] = [
+ OrganisationMemberRole.ADMIN,
+ OrganisationMemberRole.MANAGER,
+ OrganisationMemberRole.MEMBER,
+];
+
+/**
+ * Resolves the role granted to a user who confirms an organisation SSO link.
+ *
+ * The role is exactly the one the organisation configured on its portal, and is
+ * never elevated above it. The parameter is typed as a string rather than the
+ * enum because the value is read from a row this flow does not own: an
+ * unrecognised value clamps to the lowest role instead of being passed through
+ * to the membership write.
+ */
+export const resolveGrantedOrganisationRole = (configuredRole: string): OrganisationMemberRole => {
+ const permittedRole = PERMITTED_SSO_ORGANISATION_ROLES.find((role) => role === configuredRole);
+
+ return permittedRole ?? LOWEST_ORGANISATION_ROLE;
+};
+
+export const extractEmailDomain = (email: string): string | null => {
+ const separatorIndex = email.lastIndexOf('@');
+
+ if (separatorIndex === -1) {
+ return null;
+ }
+
+ const domain = email
+ .slice(separatorIndex + 1)
+ .trim()
+ .toLowerCase();
+
+ return domain.length > 0 ? domain : null;
+};
+
+const normaliseAllowedDomain = (domain: string) => {
+ return domain.trim().replace(/^@/, '').toLowerCase();
+};
+
+/**
+ * Whether an email address is permitted by a portal's `allowedDomains`.
+ *
+ * Defence in depth for the redemption path: the caller checks the same rule when
+ * the link is issued, but the portal can be reconfigured in between, so the
+ * restriction is enforced again at redemption time and fails closed. An empty
+ * list means the identity provider is the only gate, which matches the
+ * documented portal behaviour.
+ */
+export const isEmailDomainPermittedByPortal = (email: string, allowedDomains: string[]): boolean => {
+ const permittedDomains = allowedDomains.map(normaliseAllowedDomain).filter((domain) => domain.length > 0);
+
+ if (permittedDomains.length === 0) {
+ return true;
+ }
+
+ const emailDomain = extractEmailDomain(email);
+
+ return emailDomain !== null && permittedDomains.includes(emailDomain);
+};
diff --git a/packages/lib/server-only/organisation/sso/link-token.test.ts b/packages/lib/server-only/organisation/sso/link-token.test.ts
new file mode 100644
index 0000000000..7bbfda3787
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-token.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.hoisted(() => {
+ // `DOCUMENSO_ENCRYPTION_KEY` is read from the environment when
+ // `constants/crypto` is first evaluated, so it must be set before any import.
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+});
+
+import { ONE_DAY } from '../../../constants/time';
+import { AppErrorCode } from '../../../errors/app-error';
+import {
+ createOrganisationAccountLinkExpiry,
+ createOrganisationAccountLinkToken,
+ decryptOrganisationAccountLinkOauthConfig,
+ encryptOrganisationAccountLinkOauthConfig,
+ isOrganisationAccountLinkTokenExpired,
+ ORGANISATION_ACCOUNT_LINK_TOKEN_LIFETIME_MS,
+ parseOrganisationAccountLinkMetadata,
+} from './link-token';
+
+const OAUTH_CONFIG = {
+ providerAccountId: 'oidc-subject-1',
+ accessToken: 'sso-access-token-secret-value',
+ idToken: 'sso-id-token-secret-value',
+ expiresAt: 1_900_000_000,
+};
+
+describe('link tokens', () => {
+ it('issues a url safe token backed by 256 bits of entropy', () => {
+ const token = createOrganisationAccountLinkToken();
+
+ expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(Buffer.from(token, 'base64url').length).toBe(32);
+ });
+
+ it('never issues the same token twice', () => {
+ const tokens = new Set(Array.from({ length: 25 }, () => createOrganisationAccountLinkToken()));
+
+ expect(tokens.size).toBe(25);
+ });
+
+ it('expires the token well inside the 24 hour ceiling', () => {
+ const issuedAt = Date.now();
+ const expires = createOrganisationAccountLinkExpiry();
+
+ expect(ORGANISATION_ACCOUNT_LINK_TOKEN_LIFETIME_MS).toBeLessThanOrEqual(ONE_DAY);
+ expect(expires.getTime()).toBeGreaterThan(issuedAt);
+ expect(expires.getTime() - issuedAt).toBeLessThanOrEqual(ONE_DAY);
+ });
+
+ it('treats a past expiry as expired and a future expiry as live', () => {
+ expect(isOrganisationAccountLinkTokenExpired(new Date(Date.now() - 1))).toBe(true);
+ expect(isOrganisationAccountLinkTokenExpired(new Date(Date.now() + 1000))).toBe(false);
+ });
+});
+
+describe('link oauth material', () => {
+ it('round trips through the repository symmetric encryption helper', () => {
+ const encrypted = encryptOrganisationAccountLinkOauthConfig(OAUTH_CONFIG);
+
+ expect(encrypted.accessToken).not.toBe(OAUTH_CONFIG.accessToken);
+ expect(encrypted.idToken).not.toBe(OAUTH_CONFIG.idToken);
+ expect(encrypted.providerAccountId).not.toBe(OAUTH_CONFIG.providerAccountId);
+ expect(JSON.stringify(encrypted)).not.toContain(OAUTH_CONFIG.accessToken);
+ expect(JSON.stringify(encrypted)).not.toContain(OAUTH_CONFIG.idToken);
+ expect(decryptOrganisationAccountLinkOauthConfig(encrypted)).toEqual(OAUTH_CONFIG);
+ });
+
+ it('refuses tampered material without echoing it', () => {
+ const encrypted = encryptOrganisationAccountLinkOauthConfig(OAUTH_CONFIG);
+ const firstCharacter = encrypted.accessToken.slice(0, 1);
+ const tamperedAccessToken = `${firstCharacter === '0' ? '1' : '0'}${encrypted.accessToken.slice(1)}`;
+
+ let caughtError: unknown;
+
+ try {
+ decryptOrganisationAccountLinkOauthConfig({ ...encrypted, accessToken: tamperedAccessToken });
+ } catch (error) {
+ caughtError = error;
+ }
+
+ expect(caughtError).toBeInstanceOf(Error);
+ expect((caughtError as { code: string }).code).toBe(AppErrorCode.UNKNOWN_ERROR);
+ expect((caughtError as { message: string }).message).not.toContain(tamperedAccessToken);
+ });
+});
+
+describe('link metadata', () => {
+ it('parses conforming metadata', () => {
+ const metadata = parseOrganisationAccountLinkMetadata({
+ type: 'create',
+ userId: 42,
+ organisationId: 'org_123',
+ oauthConfig: encryptOrganisationAccountLinkOauthConfig(OAUTH_CONFIG),
+ });
+
+ expect(metadata).not.toBeNull();
+ expect(metadata?.type).toBe('create');
+ expect(metadata?.userId).toBe(42);
+ });
+
+ it('returns null for metadata that does not conform', () => {
+ expect(parseOrganisationAccountLinkMetadata(null)).toBeNull();
+ expect(parseOrganisationAccountLinkMetadata({ type: 'link' })).toBeNull();
+ expect(parseOrganisationAccountLinkMetadata({ type: 'unlink', userId: 1, organisationId: 'org_1' })).toBeNull();
+ });
+});
diff --git a/packages/lib/server-only/organisation/sso/link-token.ts b/packages/lib/server-only/organisation/sso/link-token.ts
new file mode 100644
index 0000000000..834320c26d
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/link-token.ts
@@ -0,0 +1,121 @@
+import crypto from 'node:crypto';
+
+import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
+import { ONE_MINUTE } from '../../../constants/time';
+import { AppError, AppErrorCode } from '../../../errors/app-error';
+import {
+ type TOrganisationAccountLinkMetadata,
+ ZOrganisationAccountLinkMetadataSchema,
+} from '../../../types/organisation';
+import { symmetricDecrypt, symmetricEncrypt } from '../../../universal/crypto';
+
+/**
+ * How long an issued confirmation link stays redeemable.
+ *
+ * Deliberately short, and kept in sync with the "Link expires in 30 minutes"
+ * copy rendered by `OrganisationAccountLinkConfirmationTemplate`.
+ */
+export const ORGANISATION_ACCOUNT_LINK_TOKEN_LIFETIME_MS = 30 * ONE_MINUTE;
+
+/**
+ * Random bytes behind a confirmation token (256 bits of entropy).
+ */
+const LINK_TOKEN_RANDOM_BYTE_LENGTH = 32;
+
+export type OrganisationAccountLinkOauthConfig = TOrganisationAccountLinkMetadata['oauthConfig'];
+
+/**
+ * A confirmation token is a bearer credential delivered by email, so it comes
+ * straight from a CSPRNG. `base64url` keeps it safe to embed in a link path.
+ */
+export const createOrganisationAccountLinkToken = () => {
+ return crypto.randomBytes(LINK_TOKEN_RANDOM_BYTE_LENGTH).toString('base64url');
+};
+
+export const createOrganisationAccountLinkExpiry = () => {
+ return new Date(Date.now() + ORGANISATION_ACCOUNT_LINK_TOKEN_LIFETIME_MS);
+};
+
+export const isOrganisationAccountLinkTokenExpired = (expires: Date) => {
+ return expires.getTime() <= Date.now();
+};
+
+const requireLinkEncryptionKey = () => {
+ if (!DOCUMENSO_ENCRYPTION_KEY) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Missing encryption key, unable to store organisation account link material',
+ });
+ }
+
+ return DOCUMENSO_ENCRYPTION_KEY;
+};
+
+const encryptLinkSecret = (key: string, data: string) => {
+ return symmetricEncrypt({ key, data });
+};
+
+const decryptLinkSecret = (key: string, data: string) => {
+ return Buffer.from(symmetricDecrypt({ key, data })).toString('utf-8');
+};
+
+/**
+ * Encrypts the OIDC material before it is persisted in
+ * `VerificationToken.metadata`.
+ *
+ * The metadata column is a plain JSONB blob that is readable by anyone with
+ * database access, and the access/id tokens inside it are live bearer
+ * credentials, so they are never stored in plaintext. `expiresAt` stays a
+ * number because the link metadata schema types it as one; it is a timestamp,
+ * not a secret.
+ */
+export const encryptOrganisationAccountLinkOauthConfig = (
+ oauthConfig: OrganisationAccountLinkOauthConfig,
+): OrganisationAccountLinkOauthConfig => {
+ const key = requireLinkEncryptionKey();
+
+ return {
+ providerAccountId: encryptLinkSecret(key, oauthConfig.providerAccountId),
+ accessToken: encryptLinkSecret(key, oauthConfig.accessToken),
+ idToken: encryptLinkSecret(key, oauthConfig.idToken),
+ expiresAt: oauthConfig.expiresAt,
+ };
+};
+
+/**
+ * Reverses {@link encryptOrganisationAccountLinkOauthConfig}.
+ *
+ * Decryption fails when the encryption key changed between issue and
+ * redemption, or when the stored material was tampered with. The failure is
+ * reported without the ciphertext, which would otherwise end up in logs and in
+ * the error surfaced to the caller.
+ */
+export const decryptOrganisationAccountLinkOauthConfig = (
+ oauthConfig: OrganisationAccountLinkOauthConfig,
+): OrganisationAccountLinkOauthConfig => {
+ const key = requireLinkEncryptionKey();
+
+ try {
+ return {
+ providerAccountId: decryptLinkSecret(key, oauthConfig.providerAccountId),
+ accessToken: decryptLinkSecret(key, oauthConfig.accessToken),
+ idToken: decryptLinkSecret(key, oauthConfig.idToken),
+ expiresAt: oauthConfig.expiresAt,
+ };
+ } catch {
+ throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
+ message: 'Unable to decrypt the organisation account link material',
+ });
+ }
+};
+
+/**
+ * Parses persisted link metadata, returning `null` when it does not conform.
+ *
+ * A `null` result is treated as a refusal by the caller rather than a thrown
+ * schema error: the row is left untouched so the failure stays explainable.
+ */
+export const parseOrganisationAccountLinkMetadata = (metadata: unknown): TOrganisationAccountLinkMetadata | null => {
+ const parsedMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(metadata);
+
+ return parsedMetadata.success ? parsedMetadata.data : null;
+};
diff --git a/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.test.ts b/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.test.ts
new file mode 100644
index 0000000000..0023c98a24
--- /dev/null
+++ b/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.test.ts
@@ -0,0 +1,264 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ // `DOCUMENSO_ENCRYPTION_KEY` and the webapp url are read from the environment
+ // when their modules are first evaluated, so they have to be in place before
+ // any import below runs.
+ process.env.NEXT_PRIVATE_ENCRYPTION_KEY = 'cleanroom-test-encryption-key';
+ process.env.NEXT_PUBLIC_WEBAPP_URL = 'https://sign.example.com';
+
+ return {
+ userFindFirst: vi.fn(),
+ verificationTokenCreate: vi.fn(),
+ auditLogCreate: vi.fn(),
+ getEmailContext: vi.fn(),
+ renderEmailWithI18N: vi.fn(),
+ getI18nInstance: vi.fn(),
+ sendMail: vi.fn(),
+ emailTemplate: () => null,
+ };
+});
+
+vi.mock('@documenso/prisma', () => ({
+ prisma: {
+ user: {
+ findFirst: mocks.userFindFirst,
+ },
+ verificationToken: {
+ create: mocks.verificationTokenCreate,
+ },
+ userSecurityAuditLog: {
+ create: mocks.auditLogCreate,
+ },
+ },
+}));
+
+vi.mock('@documenso/email/templates/organisation-account-link-confirmation', () => ({
+ OrganisationAccountLinkConfirmationTemplate: mocks.emailTemplate,
+}));
+
+vi.mock('../../../client-only/providers/i18n-server', () => ({
+ getI18nInstance: mocks.getI18nInstance,
+}));
+
+vi.mock('../../../utils/logger', () => ({
+ logger: {
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('../../../utils/render-email-with-i18n', () => ({
+ renderEmailWithI18N: mocks.renderEmailWithI18N,
+}));
+
+vi.mock('../../email/get-email-context', () => ({
+ getEmailContext: mocks.getEmailContext,
+}));
+
+import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '../../../constants/organisations';
+import { ONE_DAY } from '../../../constants/time';
+import { AppErrorCode } from '../../../errors/app-error';
+import { ZOrganisationAccountLinkMetadataSchema } from '../../../types/organisation';
+import { decryptOrganisationAccountLinkOauthConfig } from './link-token';
+import { sendOrganisationAccountLinkConfirmationEmail } from './send-sso-link-confirmation-email';
+
+const USER_ID = 42;
+const USER_EMAIL = 'alice@example.com';
+const ORGANISATION_ID = 'org_123';
+const ORGANISATION_NAME = 'Example Organisation';
+const ACCESS_TOKEN = 'sso-access-token-secret-value';
+const ID_TOKEN = 'sso-id-token-secret-value';
+const PROVIDER_ACCOUNT_ID = 'oidc-subject-1';
+const ACCESS_TOKEN_EXPIRES_AT = 1_900_000_000;
+const TOKEN_SECONDARY_ID = 'vt_secondary_1';
+
+const buildOptions = (type: 'link' | 'create') => ({
+ type,
+ userId: USER_ID,
+ organisationId: ORGANISATION_ID,
+ organisationName: ORGANISATION_NAME,
+ oauthConfig: {
+ accessToken: ACCESS_TOKEN,
+ idToken: ID_TOKEN,
+ providerAccountId: PROVIDER_ACCOUNT_ID,
+ expiresAt: ACCESS_TOKEN_EXPIRES_AT,
+ },
+});
+
+type VerificationTokenCreateArgs = {
+ data: {
+ identifier: string;
+ token: string;
+ expires: Date;
+ metadata: unknown;
+ user: { connect: { id: number } };
+ };
+};
+
+type RenderedEmailElement = {
+ props: {
+ type: 'link' | 'create';
+ confirmationLink: string;
+ organisationName: string;
+ assetBaseUrl: string;
+ };
+};
+
+type SendMailArgs = {
+ to: string;
+ from: { name: string; address: string };
+ subject: string;
+ html: string;
+ text: string;
+};
+
+const getCreatedTokenArgs = () => {
+ const [createArgs] = mocks.verificationTokenCreate.mock.calls[0] as unknown as [VerificationTokenCreateArgs];
+
+ return createArgs;
+};
+
+describe('sendOrganisationAccountLinkConfirmationEmail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.userFindFirst.mockResolvedValue({ id: USER_ID, email: USER_EMAIL });
+ mocks.verificationTokenCreate.mockResolvedValue({ id: 1, secondaryId: TOKEN_SECONDARY_ID });
+ mocks.auditLogCreate.mockResolvedValue({ id: 1 });
+ mocks.renderEmailWithI18N.mockResolvedValue('');
+ mocks.getI18nInstance.mockResolvedValue({ _: () => 'confirmation subject' });
+ mocks.sendMail.mockResolvedValue({ messageId: 'message-1' });
+ mocks.getEmailContext.mockResolvedValue({
+ branding: { brandingEnabled: false },
+ emailLanguage: 'en-US',
+ senderEmail: { name: ORGANISATION_NAME, address: 'sso@example.com' },
+ emailsDisabled: false,
+ emailTransport: { sendMail: mocks.sendMail },
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('persists the oauth material encrypted, with an expiry inside 24 hours', async () => {
+ const issuedAt = Date.now();
+
+ await sendOrganisationAccountLinkConfirmationEmail(buildOptions('link'));
+
+ expect(mocks.verificationTokenCreate).toHaveBeenCalledOnce();
+
+ const createArgs = getCreatedTokenArgs();
+
+ expect(createArgs.data.identifier).toBe(ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER);
+ expect(createArgs.data.user.connect.id).toBe(USER_ID);
+ // 256 bits of entropy, base64url encoded.
+ expect(createArgs.data.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
+
+ const storedMetadata = JSON.stringify(createArgs.data.metadata);
+
+ expect(storedMetadata).not.toContain(ACCESS_TOKEN);
+ expect(storedMetadata).not.toContain(ID_TOKEN);
+ expect(storedMetadata).not.toContain(PROVIDER_ACCOUNT_ID);
+
+ const metadata = ZOrganisationAccountLinkMetadataSchema.parse(createArgs.data.metadata);
+
+ expect(metadata.type).toBe('link');
+ expect(metadata.userId).toBe(USER_ID);
+ expect(metadata.organisationId).toBe(ORGANISATION_ID);
+ expect(decryptOrganisationAccountLinkOauthConfig(metadata.oauthConfig)).toEqual({
+ accessToken: ACCESS_TOKEN,
+ idToken: ID_TOKEN,
+ providerAccountId: PROVIDER_ACCOUNT_ID,
+ expiresAt: ACCESS_TOKEN_EXPIRES_AT,
+ });
+
+ const expiresAt = createArgs.data.expires.getTime();
+
+ expect(expiresAt).toBeGreaterThan(issuedAt);
+ expect(expiresAt - issuedAt).toBeLessThanOrEqual(ONE_DAY);
+ });
+
+ it('throws NOT_SETUP and persists nothing when the webapp url is not configured', async () => {
+ vi.stubEnv('NEXT_PUBLIC_WEBAPP_URL', '');
+
+ await expect(sendOrganisationAccountLinkConfirmationEmail(buildOptions('link'))).rejects.toMatchObject({
+ code: AppErrorCode.NOT_SETUP,
+ });
+
+ expect(mocks.verificationTokenCreate).not.toHaveBeenCalled();
+ expect(mocks.sendMail).not.toHaveBeenCalled();
+ });
+
+ it('throws NOT_FOUND when the user no longer exists', async () => {
+ mocks.userFindFirst.mockResolvedValue(null);
+
+ await expect(sendOrganisationAccountLinkConfirmationEmail(buildOptions('link'))).rejects.toMatchObject({
+ code: AppErrorCode.NOT_FOUND,
+ });
+
+ expect(mocks.verificationTokenCreate).not.toHaveBeenCalled();
+ });
+
+ it('renders the existing template through the i18n renderer and sends it via the organisation context', async () => {
+ await sendOrganisationAccountLinkConfirmationEmail(buildOptions('create'));
+
+ expect(mocks.getEmailContext).toHaveBeenCalledWith({
+ emailType: 'INTERNAL',
+ source: { type: 'organisation', organisationId: ORGANISATION_ID },
+ });
+
+ // Once for html, once for the plain text alternative.
+ expect(mocks.renderEmailWithI18N).toHaveBeenCalledTimes(2);
+ expect(mocks.renderEmailWithI18N.mock.calls[0][1]).toMatchObject({ lang: 'en-US' });
+ expect(mocks.renderEmailWithI18N.mock.calls[0][1]).not.toHaveProperty('plainText');
+ expect(mocks.renderEmailWithI18N.mock.calls[1][1]).toMatchObject({ lang: 'en-US', plainText: true });
+
+ const [renderedElement] = mocks.renderEmailWithI18N.mock.calls[0] as unknown as [RenderedEmailElement];
+ const createdToken = getCreatedTokenArgs().data.token;
+
+ expect(renderedElement.props.type).toBe('create');
+ expect(renderedElement.props.organisationName).toBe(ORGANISATION_NAME);
+ expect(renderedElement.props.assetBaseUrl).toBe('https://sign.example.com');
+ expect(renderedElement.props.confirmationLink).toBe(
+ `https://sign.example.com/organisation/sso/confirmation/${createdToken}`,
+ );
+
+ const [mailArgs] = mocks.sendMail.mock.calls[0] as unknown as [SendMailArgs];
+
+ expect(mailArgs.to).toBe(USER_EMAIL);
+ expect(mailArgs.from.address).toBe('sso@example.com');
+ expect(mailArgs.subject).toBe('confirmation subject');
+ });
+
+ it('audits the issued confirmation against the user', async () => {
+ await sendOrganisationAccountLinkConfirmationEmail(buildOptions('link'));
+
+ expect(mocks.auditLogCreate).toHaveBeenCalledOnce();
+
+ const [auditArgs] = mocks.auditLogCreate.mock.calls[0] as unknown as [{ data: Record }];
+
+ expect(auditArgs.data.userId).toBe(USER_ID);
+ // The token itself is a bearer credential and must never be audited.
+ expect(JSON.stringify(auditArgs.data)).not.toContain(getCreatedTokenArgs().data.token);
+ });
+
+ it('fails loudly when the organisation is not allowed to send email', async () => {
+ mocks.getEmailContext.mockResolvedValue({
+ branding: { brandingEnabled: false },
+ emailLanguage: 'en-US',
+ senderEmail: { name: ORGANISATION_NAME, address: 'sso@example.com' },
+ emailsDisabled: true,
+ emailTransport: { sendMail: mocks.sendMail },
+ });
+
+ await expect(sendOrganisationAccountLinkConfirmationEmail(buildOptions('link'))).rejects.toMatchObject({
+ code: AppErrorCode.NOT_SETUP,
+ });
+
+ expect(mocks.verificationTokenCreate).not.toHaveBeenCalled();
+ expect(mocks.sendMail).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts b/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
index d3f0ff468e..533d3c0604 100644
--- a/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
+++ b/packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
@@ -1,124 +1,192 @@
-import crypto from 'node:crypto';
-import { mailer } from '@documenso/email/mailer';
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
-import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
-import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
-import { DOCUMENSO_INTERNAL_EMAIL } from '@documenso/lib/constants/email';
-import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '@documenso/lib/constants/organisations';
-import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
-import { getEmailContext } from '@documenso/lib/server-only/email/get-email-context';
-import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
-import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
-import { DateTime } from 'luxon';
import { createElement } from 'react';
-export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
+import { getI18nInstance } from '../../../client-only/providers/i18n-server';
+import { formatPath, NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
+import { ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER } from '../../../constants/organisations';
+import { AppError, AppErrorCode } from '../../../errors/app-error';
+import type { TOrganisationAccountLinkMetadata } from '../../../types/organisation';
+import { env } from '../../../utils/env';
+import { logger } from '../../../utils/logger';
+import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
+import { getEmailContext } from '../../email/get-email-context';
+import { writeOrganisationSsoLinkAuditLog } from './link-audit';
+import {
+ createOrganisationAccountLinkExpiry,
+ createOrganisationAccountLinkToken,
+ encryptOrganisationAccountLinkOauthConfig,
+} from './link-token';
+
+/**
+ * Route served by
+ * `apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx`.
+ */
+const ORGANISATION_SSO_CONFIRMATION_PATH = '/organisation/sso/confirmation';
+
+export type SendOrganisationAccountLinkConfirmationEmailOptions = {
+ /**
+ * `create` when the account was provisioned by the SSO sign-in itself (and
+ * therefore has no password), `link` when an existing account is being
+ * attached to the organisation.
+ */
+ type: 'link' | 'create';
+ userId: number;
+ organisationId: string;
organisationName: string;
+ oauthConfig: {
+ accessToken: string;
+ idToken: string;
+ providerAccountId: string;
+ /**
+ * Access token expiry, in unix seconds.
+ */
+ expiresAt: number;
+ };
+};
+
+/**
+ * Builds the absolute base URL confirmation links are rooted at.
+ *
+ * `NEXT_PUBLIC_WEBAPP_URL()` silently falls back to localhost, which would hand
+ * users a link that only resolves on a developer machine, so the raw variable is
+ * asserted here instead of relying on that fallback.
+ */
+const getWebappBaseUrl = () => {
+ const configuredBaseUrl = env('NEXT_PUBLIC_WEBAPP_URL');
+
+ if (!configuredBaseUrl) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'NEXT_PUBLIC_WEBAPP_URL is not configured, unable to build an account link confirmation url',
+ });
+ }
+
+ return NEXT_PUBLIC_WEBAPP_URL();
};
+/**
+ * Issues the single-use confirmation link that authorises an organisation SSO
+ * account link, and emails it to the address the identity provider asserted.
+ *
+ * Called from the organisation OIDC callback while the user is *not* yet a
+ * member of the organisation: membership is granted only by
+ * `linkOrganisationAccount`, once the recipient has proven control of the inbox
+ * the link was delivered to.
+ */
export const sendOrganisationAccountLinkConfirmationEmail = async ({
type,
userId,
organisationId,
organisationName,
oauthConfig,
-}: SendOrganisationAccountLinkConfirmationEmailProps) => {
+}: SendOrganisationAccountLinkConfirmationEmailOptions): Promise => {
+ const baseUrl = getWebappBaseUrl();
+
const user = await prisma.user.findFirst({
where: {
id: userId,
},
- include: {
- verificationTokens: {
- where: {
- identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
- },
- orderBy: {
- createdAt: 'desc',
- },
- take: 1,
- },
+ select: {
+ id: true,
+ email: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
- message: 'User not found',
+ message: 'Unable to find the user requesting an organisation account link',
});
}
- const [previousVerificationToken] = user.verificationTokens;
+ // Sent through the organisation's own email context so a configured sending
+ // domain and branding apply; `getEmailContext` falls back to the global
+ // mailer when the organisation has no custom sender.
+ const { branding, emailLanguage, senderEmail, emailsDisabled, emailTransport } = await getEmailContext({
+ emailType: 'INTERNAL',
+ source: {
+ type: 'organisation',
+ organisationId,
+ },
+ });
- // Rate-limit resend: If sent within the last 5 minutes, skip
- if (
- previousVerificationToken?.createdAt &&
- DateTime.fromJSDate(previousVerificationToken.createdAt).diffNow('minutes').minutes > -5
- ) {
- return;
+ // `getEmailContext` is authoritative on whether an organisation may send mail
+ // at all. Fail loudly rather than returning quietly: the caller redirects the
+ // browser to a "verification required" page either way, so a silent return
+ // would leave the user waiting on an email that was never sent.
+ if (emailsDisabled) {
+ logger.warn({
+ msg: 'Skipped organisation account link confirmation, organisation emails are disabled',
+ userId: user.id,
+ organisationId,
+ });
+
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Emails are disabled for this organisation, unable to send the account link confirmation',
+ userMessage:
+ 'Single sign-on is unavailable for this organisation because email is disabled. Please contact your administrator.',
+ });
}
- const token = crypto.randomBytes(20).toString('hex');
+ const token = createOrganisationAccountLinkToken();
+ const encryptedOauthConfig = encryptOrganisationAccountLinkOauthConfig(oauthConfig);
- const createdToken = await prisma.verificationToken.create({
+ const createdVerificationToken = await prisma.verificationToken.create({
data: {
identifier: ORGANISATION_ACCOUNT_LINK_VERIFICATION_TOKEN_IDENTIFIER,
token,
- expires: DateTime.now().plus({ minutes: 30 }).toJSDate(),
+ expires: createOrganisationAccountLinkExpiry(),
metadata: {
type,
- userId,
+ userId: user.id,
organisationId,
- oauthConfig,
+ oauthConfig: { ...encryptedOauthConfig },
} satisfies TOrganisationAccountLinkMetadata,
- userId,
- },
- });
-
- const { emailLanguage } = await getEmailContext({
- emailType: 'INTERNAL',
- source: {
- type: 'organisation',
- organisationId,
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
},
- meta: null,
});
- // Fail closed instead of silently linking verification emails to a
- // hardcoded production domain when NEXT_PUBLIC_WEBAPP_URL is missing (a
- // self-hosted deployment would otherwise email links pointing at crove.com).
- const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL();
-
- if (!assetBaseUrl) {
- throw new Error(
- 'NEXT_PUBLIC_WEBAPP_URL is required to send the SSO account link confirmation email',
- );
- }
+ await writeOrganisationSsoLinkAuditLog({ userId: user.id });
- const confirmationLink = `${assetBaseUrl}/organisation/sso/confirmation/${createdToken.token}`;
+ const confirmationLink = new URL(formatPath(`${ORGANISATION_SSO_CONFIRMATION_PATH}/${token}`), baseUrl).toString();
- const confirmationTemplate = createElement(OrganisationAccountLinkConfirmationTemplate, {
+ const template = createElement(OrganisationAccountLinkConfirmationTemplate, {
type,
- assetBaseUrl,
confirmationLink,
organisationName,
+ assetBaseUrl: baseUrl,
});
const [html, text] = await Promise.all([
- renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage }),
- renderEmailWithI18N(confirmationTemplate, { lang: emailLanguage, plainText: true }),
+ renderEmailWithI18N(template, { lang: emailLanguage, branding }),
+ renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
- return mailer.sendMail({
- to: {
- address: user.email,
- name: user.name || '',
- },
- from: DOCUMENSO_INTERNAL_EMAIL,
- subject: type === 'create' ? i18n._(msg`Account creation request`) : i18n._(msg`Account linking request`),
+ const subject =
+ type === 'create'
+ ? msg`${organisationName} requested to create your Documenso account`
+ : msg`${organisationName} requested to link your Documenso account`;
+
+ await emailTransport.sendMail({
+ to: user.email,
+ from: senderEmail,
+ subject: i18n._(subject),
html,
text,
});
+
+ // The raw token is a bearer credential and the OAuth material is secret, so
+ // only the non-sensitive secondary id is logged.
+ logger.info({
+ msg: 'Organisation account link confirmation issued',
+ userId: user.id,
+ organisationId,
+ tokenSecondaryId: createdVerificationToken.secondaryId,
+ });
};
diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po
index 04082d80b0..08f338880a 100644
--- a/packages/lib/translations/en/web.po
+++ b/packages/lib/translations/en/web.po
@@ -32,6 +32,23 @@ msgstr "\"{0}\" will appear on the document as it has a timezone of \"{1}\"."
msgid "\"{documentName}\" has been deleted by an admin."
msgstr "\"{documentName}\" has been deleted by an admin."
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
+msgid "Custom sending domains are disabled on this installation."
+msgstr "Custom sending domains are disabled on this installation."
+
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
+msgid "Single sign-on is disabled on this installation."
+msgstr "Single sign-on is disabled on this installation."
+
+#: packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
+msgid "{organisationName} requested to create your Documenso account"
+msgstr "{organisationName} requested to create your Crove Sign account"
+
+#: packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
+msgid "{organisationName} requested to link your Documenso account"
+msgstr "{organisationName} requested to link your Crove Sign account"
+
#: packages/email/template-components/template-document-pending.tsx
msgid "“{documentName}” has been signed"
msgstr "“{documentName}” has been signed"
diff --git a/packages/lib/translations/vi/web.po b/packages/lib/translations/vi/web.po
index 47c536813c..3b569eac22 100644
--- a/packages/lib/translations/vi/web.po
+++ b/packages/lib/translations/vi/web.po
@@ -20,6 +20,23 @@ msgstr "\"{0}\" will appear on the document as it has a timezone of \"{1}\"."
msgid "\"{documentName}\" has been deleted by an admin."
msgstr "\"{documentName}\" has been deleted by an admin."
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx
+msgid "Custom sending domains are disabled on this installation."
+msgstr "Tên miền gửi email tùy chỉnh đã bị tắt trên hệ thống này."
+
+#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
+msgid "Single sign-on is disabled on this installation."
+msgstr "Đăng nhập một lần (SSO) đã bị tắt trên hệ thống này."
+
+#: packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
+msgid "{organisationName} requested to create your Documenso account"
+msgstr "{organisationName} đã yêu cầu tạo tài khoản Crove Sign của bạn"
+
+#: packages/lib/server-only/organisation/sso/send-sso-link-confirmation-email.ts
+msgid "{organisationName} requested to link your Documenso account"
+msgstr "{organisationName} đã yêu cầu liên kết tài khoản Crove Sign của bạn"
+
#: packages/email/template-components/template-document-pending.tsx
msgid "“{documentName}” has been signed"
msgstr "“{documentName}” has been signed"
diff --git a/packages/lib/utils/env.ts b/packages/lib/utils/env.ts
index 6699d79004..2aada5bdfa 100644
--- a/packages/lib/utils/env.ts
+++ b/packages/lib/utils/env.ts
@@ -56,6 +56,11 @@ export const createPublicEnv = () => ({
// Derived from the private transport so the client can detect CSC mode for
// authoring UI gating without exposing the raw transport value.
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
+ // Derived from the private instance flags for the in-house enterprise
+ // features, so client-side navigation cannot drift from what the server will
+ // actually allow.
+ NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED: process.env.CROVE_FEATURE_EMAIL_DOMAINS !== 'false' ? 'true' : 'false',
+ NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED: process.env.CROVE_FEATURE_SSO_PORTAL !== 'false' ? 'true' : 'false',
// Derived from the private Vertex credentials so the client can gate AI
// feature UI on a boolean.
NEXT_PUBLIC_AI_FEATURES_ENABLED:
diff --git a/packages/lib/utils/settings-nav.ts b/packages/lib/utils/settings-nav.ts
index d2088a98dd..bae43cb4fe 100644
--- a/packages/lib/utils/settings-nav.ts
+++ b/packages/lib/utils/settings-nav.ts
@@ -18,7 +18,7 @@ import {
} from 'lucide-react';
import type { ComponentType } from 'react';
import { FaUsers } from 'react-icons/fa6';
-import { IS_BILLING_ENABLED } from '../constants/app';
+import { IS_BILLING_ENABLED, IS_EMAIL_DOMAINS_ENABLED, IS_SSO_PORTAL_ENABLED } from '../constants/app';
import { canExecuteOrganisationAction } from './organisations';
import { canExecuteTeamAction } from './teams';
@@ -73,6 +73,8 @@ export const getSettingsNavGroups = ({
hasManageableBillingOrgs,
}: GetSettingsNavGroupsArgs): SettingsNavGroups => {
const isBillingEnabled = IS_BILLING_ENABLED();
+ const isEmailDomainsEnabled = IS_EMAIL_DOMAINS_ENABLED();
+ const isSsoPortalEnabled = IS_SSO_PORTAL_ENABLED();
const canManageOrg =
organisation !== null && canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole);
@@ -126,15 +128,18 @@ export const getSettingsNavGroups = ({
label: msg`Certificates`,
isSubNav: true,
},
- // Email Domains and SSO settings pages are unconditionally reachable;
- // the nav must match the pages instead of hiding entries behind
- // billing flags that the pages no longer enforce.
- {
- key: 'email-domains',
- path: `/o/${organisation.url}/settings/email-domains`,
- label: msg`Email Domains`,
- icon: MailboxIcon,
- },
+ // The nav mirrors what the API enforces, so both entries disappear
+ // when the installation turns the feature off.
+ ...(isEmailDomainsEnabled
+ ? [
+ {
+ key: 'email-domains',
+ path: `/o/${organisation.url}/settings/email-domains`,
+ label: msg`Email Domains`,
+ icon: MailboxIcon,
+ },
+ ]
+ : []),
{
key: 'teams',
path: `/o/${organisation.url}/settings/teams`,
@@ -153,12 +158,16 @@ export const getSettingsNavGroups = ({
label: msg`Groups`,
icon: GroupIcon,
},
- {
- key: 'sso',
- path: `/o/${organisation.url}/settings/sso`,
- label: msg`SSO`,
- icon: ShieldCheckIcon,
- },
+ ...(isSsoPortalEnabled
+ ? [
+ {
+ key: 'sso',
+ path: `/o/${organisation.url}/settings/sso`,
+ label: msg`SSO`,
+ icon: ShieldCheckIcon,
+ },
+ ]
+ : []),
...(isBillingEnabled
? [
{
diff --git a/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts
index 8206069b90..ac40651134 100644
--- a/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts
+++ b/packages/trpc/server/enterprise-router/create-organisation-email-domain.ts
@@ -1,6 +1,7 @@
-import { createEmailDomain } from '@documenso/lib/server-only/email-domain/create-email-domain';
+import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { createEmailDomain } from '@documenso/lib/server-only/email-domain/create-email-domain';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -24,6 +25,12 @@ export const createOrganisationEmailDomainRoute = authenticatedProcedure
},
});
+ if (!IS_EMAIL_DOMAINS_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Custom sending domains are disabled on this installation',
+ });
+ }
+
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
diff --git a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts
index ba30c00d69..2851d1861e 100644
--- a/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts
+++ b/packages/trpc/server/enterprise-router/delete-organisation-email-domain.ts
@@ -1,6 +1,7 @@
-import { deleteEmailDomain } from '@documenso/lib/server-only/email-domain/delete-email-domain';
+import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { deleteEmailDomain } from '@documenso/lib/server-only/email-domain/delete-email-domain';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -23,6 +24,12 @@ export const deleteOrganisationEmailDomainRoute = authenticatedProcedure
},
});
+ if (!IS_EMAIL_DOMAINS_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Custom sending domains are disabled on this installation',
+ });
+ }
+
const emailDomain = await prisma.emailDomain.findFirst({
where: {
id: emailDomainId,
diff --git a/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts b/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts
index eac7e79ec9..27a2a93bbf 100644
--- a/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts
+++ b/packages/trpc/server/enterprise-router/get-organisation-authentication-portal.ts
@@ -1,3 +1,4 @@
+import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
@@ -36,6 +37,12 @@ export const getOrganisationAuthenticationPortal = async ({
userId,
organisationId,
}: GetOrganisationAuthenticationPortalOptions) => {
+ if (!IS_SSO_PORTAL_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'The organisation SSO portal is disabled on this installation',
+ });
+ }
+
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
diff --git a/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts b/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts
index 4021938143..c383679342 100644
--- a/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts
+++ b/packages/trpc/server/enterprise-router/update-organisation-authentication-portal.ts
@@ -1,4 +1,4 @@
-import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
+import { IS_SSO_PORTAL_ENABLED } from '@documenso/lib/constants/app';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@@ -25,6 +25,12 @@ export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedur
},
});
+ if (!IS_SSO_PORTAL_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'The organisation SSO portal is disabled on this installation',
+ });
+ }
+
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
diff --git a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts
index 2744ee8a5b..de1129cf49 100644
--- a/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts
+++ b/packages/trpc/server/enterprise-router/verify-organisation-email-domain.ts
@@ -1,6 +1,7 @@
-import { verifyEmailDomain } from '@documenso/lib/server-only/email-domain/verify-email-domain';
+import { IS_EMAIL_DOMAINS_ENABLED } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
+import { verifyEmailDomain } from '@documenso/lib/server-only/email-domain/verify-email-domain';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -24,6 +25,12 @@ export const verifyOrganisationEmailDomainRoute = authenticatedProcedure
},
});
+ if (!IS_EMAIL_DOMAINS_ENABLED()) {
+ throw new AppError(AppErrorCode.NOT_SETUP, {
+ message: 'Custom sending domains are disabled on this installation',
+ });
+ }
+
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts
index 759d522d4b..7ec1d61e93 100644
--- a/packages/tsconfig/process-env.d.ts
+++ b/packages/tsconfig/process-env.d.ts
@@ -140,5 +140,19 @@ declare namespace NodeJS {
GOOGLE_VERTEX_API_KEY?: string;
GOOGLE_VERTEX_SERVICE_ACCOUNT_KEY?: string;
GOOGLE_VERTEX_USE_ADC?: string;
+
+ /**
+ * In-house feature flags for the custom sending domain and organisation SSO
+ * portal features. Both default to enabled; set to `false` to switch a
+ * feature off instance-wide.
+ */
+ CROVE_FEATURE_EMAIL_DOMAINS?: 'true' | 'false';
+ CROVE_FEATURE_SSO_PORTAL?: 'true' | 'false';
+ /**
+ * Derived from the two flags above in `createPublicEnv()`; do not set
+ * manually. Lets client-side navigation match what the API will allow.
+ */
+ NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED?: 'true' | 'false';
+ NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED?: 'true' | 'false';
}
}