179 lines
7.3 KiB
TypeScript
179 lines
7.3 KiB
TypeScript
// 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<string, string | undefined> }): Promise<EmailDomainContext> {
|
|
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<void> {
|
|
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 },
|
|
});
|
|
});
|
|
}
|