Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/emailDomainService.server.ts
T

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 },
});
});
}