// AK-P14a · P14 Email — sending-domain settings service. // // Backs the Sende-Domain screen: read the tenant's sending domain (+ its // BCC-logging inbound address), add a domain (registers it with Mailjet and // stores the DKIM/SPF records the tenant must publish), and toggle BCC logging. // The 5-minute worker probe (email-domain-probe-cron) drives the verification // status; this service never blocks on DNS. // // Every tenant DB op runs inside withTenantRLS() so the policy binds at the P5 // cutover. The Mailjet provisioner is the EU SMTP abstraction (AD-6); it is // optional at runtime — without credentials the domain row is still created // (status pending) and the cron verifies DNS independently. import { createMailjetDnsProvisioner, type MailjetDnsProvisioner } from "@hivegrid/mail"; import { CatalogError, foundationErrors } from "@hivegrid/foundation"; import { featureKeys } from "@hivegrid/entitlements"; import { requireAuth } from "~/utils/loaders.middleware"; import { getTenantIdFromUrl } from "~/utils/services/.server/urlService"; import { getUserInfo } from "~/utils/session.server"; import { getPermissionsByUser } from "~/utils/db/permissions/userRoles.db.server"; import { withTenantRLS } from "~/utils/db/.server/tenantRls.server"; import { requireTenantProductAccess } from "~/utils/entitlements/.server/productAccess.server"; const BCC_LOCAL_PART = "crm"; const BCC_INBOUND_HOST = "bcc.hivegrid.de"; const DEFAULT_DMARC_RECORD = "v=DMARC1; p=none"; const DOMAIN_PATTERN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; export type EmailDomainContext = { tenantId: string; userId: string; permissions: readonly string[]; }; export async function loadEmailDomainContext({ request, params }: { request: Request; params: Record }): Promise { await requireAuth({ request, params }); const [tenantId, userInfo] = await Promise.all([getTenantIdFromUrl(params), getUserInfo(request)]); if (!tenantId || !userInfo.userId) { throw new CatalogError(foundationErrors.notFound); } // Child resource routes can be called directly without executing their // layout loader. Enforce the paid add-on and billing status at the shared // context boundary every email-marketing route already uses. await requireTenantProductAccess({ request, params, featureKey: featureKeys.emailMarketing }); const permissions = await getPermissionsByUser(userInfo.userId, tenantId); return { tenantId, userId: userInfo.userId, permissions }; } export function emailDomainCanView(ctx: EmailDomainContext): boolean { return ctx.permissions.includes("email.domain.view") || ctx.permissions.includes("email.domain.manage"); } export function emailDomainCanManage(ctx: EmailDomainContext): boolean { return ctx.permissions.includes("email.domain.manage"); } /** Permission-denied surfaces as 404, never 403 (Bauprinzip A.5.3). */ export function notFoundResponse(): Response { return new Response("Not Found", { status: 404 }); } export function badRequestResponse(): Response { return new Response("Bad Request", { status: 400 }); } /** Pure: normalize + validate a domain input. Returns null when invalid. */ export function normalizeDomainInput(raw: string): string | null { const domain = raw.trim().toLowerCase().replace(/\.$/, ""); return DOMAIN_PATTERN.test(domain) ? domain : null; } /** Deterministic, display-only BCC address for a tenant. */ export function bccAddressFor(tenantSlug: string): string { const safeSlug = tenantSlug.trim().toLowerCase().replace(/[^a-z0-9-]/g, "") || "tenant"; return `${BCC_LOCAL_PART}.${safeSlug}@${BCC_INBOUND_HOST}`; } function envProvisioner(): MailjetDnsProvisioner | null { const apiKey = process.env.MAILJET_API_KEY; const secretKey = process.env.MAILJET_SECRET_KEY; if (!apiKey || !secretKey) { return null; } return createMailjetDnsProvisioner({ apiKey, secretKey }); } export type SendingDomainStateRow = { id: string; domain: string; status: "pending" | "verifying" | "verified" | "failed"; dkimSelector: string | null; dkimRecord: string | null; spfRecord: string | null; dmarcRecord: string | null; lastError: string | null; }; export async function getSendingDomainState(tenantId: string): Promise<{ domain: SendingDomainStateRow | null; bccEnabled: boolean }> { return withTenantRLS(tenantId, async (tx) => { const row = await tx.tenantSendingDomain.findFirst({ where: { tenantId, archivedAt: null }, orderBy: { createdAt: "desc" } }); const bcc = await tx.tenantInboundAddress.findFirst({ where: { tenantId, purpose: "bcc_logging", archivedAt: null } }); const domain: SendingDomainStateRow | null = row ? { id: row.id, domain: row.domain, status: row.verificationStatus, dkimSelector: row.dkimSelector, dkimRecord: row.dkimRecord, spfRecord: row.spfRecord, dmarcRecord: row.dmarcRecord, lastError: row.lastError, } : null; return { domain, bccEnabled: bcc?.enabled ?? false }; }); } /** * Add (or replace) the tenant sending domain. Registers it with Mailjet and * stores the published DKIM/SPF records; falls back to a record-less pending row * when Mailjet credentials are absent. Soft-archives any prior domain. */ export async function createSendingDomain( tenantId: string, domain: string, now: Date = new Date(), deps: { provisioner?: MailjetDnsProvisioner | null } = {}, ): Promise<{ id: string }> { const provisioner = deps.provisioner === undefined ? envProvisioner() : deps.provisioner; let dkimSelector: string | null = null; let dkimRecord: string | null = null; let spfRecord: string | null = null; let mailjetDnsId: string | null = null; if (provisioner) { await provisioner.registerDomain(domain).catch(() => undefined); const records = await provisioner.getRecords(domain).catch(() => null); if (records) { dkimSelector = records.dkimSelector; dkimRecord = records.dkimRecord; spfRecord = records.spfRecord; mailjetDnsId = records.dnsId; } } return withTenantRLS(tenantId, async (tx) => { await tx.tenantSendingDomain.updateMany({ where: { tenantId, archivedAt: null }, data: { archivedAt: now } }); const created = await tx.tenantSendingDomain.create({ data: { tenantId, domain, dkimSelector, dkimRecord, spfRecord, dmarcRecord: DEFAULT_DMARC_RECORD, mailjetDnsId, verificationStatus: "pending", firstProbeAt: now, }, select: { id: true }, }); return created; }); } /** Enable/disable BCC logging by upserting the tenant bcc_logging inbound address. */ export async function setBccLogging(tenantId: string, tenantSlug: string, enabled: boolean): Promise { const address = bccAddressFor(tenantSlug); await withTenantRLS(tenantId, async (tx) => { const existing = await tx.tenantInboundAddress.findFirst({ where: { tenantId, purpose: "bcc_logging" } }); if (existing) { await tx.tenantInboundAddress.update({ where: { id: existing.id }, data: { enabled, archivedAt: null } }); return; } await tx.tenantInboundAddress.create({ data: { tenantId, address, fullAddress: address, localPart: BCC_LOCAL_PART, purpose: "bcc_logging", enabled }, }); }); }