12 KiB
date, title
| date | title |
|---|---|
| 2026-02-24 | Custom Email Domain Sync And Recovery |
Problem Statement
Custom email domains configured via AWS SES can get stuck in a PENDING state or fail validation silently. Currently, there is no automated verification -- users must manually click "Sync" in the UI to check domain status. If a domain fails to validate, the only option is to delete it and recreate it, which generates new DKIM keys and requires the user to update their DNS records.
Current Pain Points
- No background sync -- Domain verification status is never checked automatically; users must manually click "Sync"
- Stuck domains -- Domains can remain in
PENDINGstate indefinitely with no alerting or auto-recovery - Failed recovery requires DNS changes -- Deleting and recreating a domain generates new keys, forcing the user to update DNS records
- No visibility into failure duration -- There's no tracking of how long a domain has been pending
Proposed Solution
1. Hourly Background Sync Job
Create a new cron job (internal.sync-email-domains) that runs every hour to automatically verify all PENDING email domains.
Job Definition: packages/lib/jobs/definitions/internal/sync-email-domains.ts
Job Handler: packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts
Pattern: Follow the existing cleanup-rate-limits cron job pattern:
cron: '0 * * * *'(every hour, on the hour)- Empty
z.object({})schema (no payload needed) - Register in
packages/lib/jobs/client.ts
Handler Logic:
- Query all
EmailDomainrecords withstatus: 'PENDING' - For each domain, call
verifyEmailDomain(emailDomainId)which:- Calls AWS SES
GetEmailIdentityCommandto check current verification status - Updates DB status to
ACTIVEif verified, keepsPENDINGotherwise
- Calls AWS SES
- Log results via
io.logger(how many checked, how many transitioned to ACTIVE) - Process domains in batches to avoid overwhelming SES API rate limits
- Add error handling per-domain so one failure doesn't stop the entire sweep
2. Schema Changes -- Track Pending Duration
Add a lastVerifiedAt column to the EmailDomain model to track when verification was last attempted, enabling "stale domain" detection.
File: packages/prisma/schema.prisma
model EmailDomain {
// ... existing fields ...
lastVerifiedAt DateTime? // Last time verification was checked against SES
}
Migration: Create a new Prisma migration for this column addition.
Updates needed:
verify-email-domain.ts-- UpdatelastVerifiedAtwhen verification is checked- The sync job handler -- Use
lastVerifiedAtto avoid re-checking domains that were just verified
3. Domain Re-registration (Recovery) -- Delete & Recreate in SES Without Changing Keys
Add a new "Re-register" action that deletes the SES identity and recreates it using the same DKIM key pair stored in the database, so the user's DNS records remain valid.
3a. New Service Function
File: packages/ee/server-only/lib/reregister-email-domain.ts
export const reregisterEmailDomain = async (options: { emailDomainId: string }) => {
// 1. Fetch the EmailDomain record (including encrypted privateKey)
// 2. Decrypt the private key using DOCUMENSO_ENCRYPTION_KEY
// 3. Call DeleteEmailIdentityCommand on SES (ignore NotFoundException)
// 4. Call CreateEmailIdentityCommand with BYODKIM using the SAME selector + private key
// 5. Update EmailDomain status back to PENDING, update lastVerifiedAt
// 6. Return the updated domain
};
Key points:
- Uses the existing encrypted
privateKeyfrom the DB -- no new key generation - Uses the existing
selector-- DNS records stay the same - Deletes first, then recreates -- handles cases where SES state is corrupted
- Resets status to
PENDINGsince verification will need to re-occur - Uses
verifyDomainWithDKIM()fromcreate-email-domain.ts(may need to extract/export this helper)
3b. Admin TRPC Routes (Find, Get, Re-register)
All email domain admin routes use adminProcedure -- requires system-level Role.ADMIN.
Find (list) route:
File: packages/trpc/server/admin-router/find-email-domains.ts
Types: packages/trpc/server/admin-router/find-email-domains.types.ts
- Query route:
admin.emailDomain.find - Input:
{ query?: string, page?: number, perPage?: number, status?: EmailDomainStatus } - Extends
ZFindSearchParamsSchemawith optionalstatusfilter - Returns standard
ZFindResultResponsewith email domain data including: id, domain, status, selector, createdAt, lastVerifiedAt, organisation name, email count - Prisma query filters by domain name (LIKE search on
query), optional status, joins organisation for name, counts emails
Get (detail) route:
File: packages/trpc/server/admin-router/get-email-domain.ts
Types: packages/trpc/server/admin-router/get-email-domain.types.ts
- Query route:
admin.emailDomain.get - Input:
{ emailDomainId: string } - Returns full email domain detail: all fields (except privateKey), organisation info, list of associated emails, DNS records (generated from publicKey + selector)
- Omits
privateKeyfrom response
Re-register (mutation) route:
File: packages/trpc/server/admin-router/reregister-email-domain.ts
Types: packages/trpc/server/admin-router/reregister-email-domain.types.ts
- Mutation route:
admin.emailDomain.reregister - Input:
{ emailDomainId: string } - Calls
reregisterEmailDomain() - Rationale: Re-registration is a recovery/operational action that deletes and recreates an SES identity. This is a privileged operation that should only be performed by platform operators, not self-service by org admins.
3c. Register in Admin Router
File: packages/trpc/server/admin-router/router.ts
Add a new emailDomain namespace to the admin router:
emailDomain: {
find: findEmailDomainsRoute,
get: getEmailDomainRoute,
reregister: reregisterEmailDomainRoute,
},
3d. Admin Panel UI -- Email Domains Section
List page: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx
- New admin panel page at
/admin/email-domains - Follow the existing admin documents list pattern (client-side TRPC data fetching)
- Search input (debounced) filtering by domain name
- Status filter dropdown (All / Pending / Active)
- DataTable with columns: Domain, Organisation, Status (badge), Email Count, Created, Last Verified, Actions
- Actions dropdown per row: View details, Re-register
- Pagination via
DataTablePagination
Detail page: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
- Shows full domain details: domain, selector, status, organisation, created date, last verified date
- Shows DNS records (DKIM + SPF) with copy buttons (reuse
organisation-email-domain-records-dialogpattern) - Table of associated organisation emails
- "Re-register" button with confirmation dialog explaining the action (SES identity will be deleted and recreated with the same keys)
- "Verify Now" button to manually trigger a verification check
- Shows how long the domain has been pending (using
lastVerifiedAtorcreatedAt)
Navigation: Add menu item to admin sidebar in _layout.tsx:
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/email-domains') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/email-domains">
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Domains</Trans>
</Link>
</Button>
Table component: apps/remix/app/components/tables/admin-email-domains-table.tsx (optional -- can be inline in the route file like the documents page)
3e. Automatic Re-registration in Sync Job (Optional Enhancement)
In the hourly sync job, after checking verification status, if a domain has been PENDING for more than 48 hours:
- Automatically call
reregisterEmailDomain()to attempt recovery - Log the auto-recovery attempt
- This provides a self-healing mechanism without user intervention
Implementation Plan
Phase 1: Background Sync Job (Core)
- Create
sync-email-domains.tsjob definition with hourly cron - Create
sync-email-domains.handler.tswith batch verification logic - Register job in
packages/lib/jobs/client.ts - Add error handling and logging
Phase 2: Schema Enhancement
- Add
lastVerifiedAtcolumn toEmailDomainmodel - Create Prisma migration
- Update
verifyEmailDomain()to setlastVerifiedAton each check - Update sync job to use
lastVerifiedAtfor intelligent scheduling
Phase 3: Admin Email Domains Panel
- Create
find-email-domainsadmin TRPC route + types (list/search with pagination and status filter) - Create
get-email-domainadmin TRPC route + types (detail view with org info, emails, DNS records) - Register find + get routes in admin router under
emailDomainnamespace - Create admin list page (
admin+/email-domains._index.tsx) with search, status filter, DataTable - Create admin detail page (
admin+/email-domains.$id.tsx) with domain info, emails table, DNS records - Add "Email Domains" menu item to admin sidebar (
_layout.tsx)
Phase 4: Re-registration Feature
- Extract
verifyDomainWithDKIM()as a shared helper (if not already exported) - Create
reregisterEmailDomain()service function - Create
reregister-email-domainadmin TRPC mutation route + types - Register reregister route in admin router under
emailDomain.reregister - Add "Re-register" button + confirmation dialog on admin detail page
Phase 5: Auto-Recovery (Optional)
- Add 48-hour stale detection logic to sync job
- Auto-trigger re-registration for stale domains
- Add logging/notifications for auto-recovery events
Files to Create/Modify
New Files
packages/lib/jobs/definitions/internal/sync-email-domains.tspackages/lib/jobs/definitions/internal/sync-email-domains.handler.tspackages/ee/server-only/lib/reregister-email-domain.tspackages/trpc/server/admin-router/find-email-domains.tspackages/trpc/server/admin-router/find-email-domains.types.tspackages/trpc/server/admin-router/get-email-domain.tspackages/trpc/server/admin-router/get-email-domain.types.tspackages/trpc/server/admin-router/reregister-email-domain.tspackages/trpc/server/admin-router/reregister-email-domain.types.tsapps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsxapps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
Modified Files
packages/prisma/schema.prisma-- AddlastVerifiedAtfieldpackages/lib/jobs/client.ts-- Register new sync jobpackages/ee/server-only/lib/verify-email-domain.ts-- UpdatelastVerifiedAtpackages/ee/server-only/lib/create-email-domain.ts-- ExportverifyDomainWithDKIMhelperpackages/trpc/server/admin-router/router.ts-- AddemailDomain.{find, get, reregister}routesapps/remix/app/routes/_authenticated+/admin+/_layout.tsx-- Add "Email Domains" nav item to sidebar- New Prisma migration file
Technical Considerations
-
SES API Rate Limits -- AWS SES has rate limits on
GetEmailIdentityCommand. The sync job should process domains in batches with small delays between calls (e.g., 5-10 per batch with 1s delay). -
Concurrency -- The local job provider has deterministic deduplication via SHA-256 IDs, so multiple app instances won't run the same cron tick twice.
-
Error Isolation -- Each domain verification in the sync job should be wrapped in try/catch so one failing domain doesn't prevent others from being checked.
-
Re-registration Safety -- The re-register function should be idempotent. Deleting a non-existent SES identity should be handled gracefully (already done in
deleteEmailDomain). -
Private Key Security -- The private key is encrypted at rest and should only be decrypted transiently during re-registration. It should never be logged or exposed in API responses.
-
Feature Gating -- The sync job should only process domains belonging to organisations with active
emailDomainsclaim flags. This prevents processing domains for orgs that have downgraded. -
Observability -- Add structured logging to the sync job so operations teams can monitor domain verification health across all tenants.