Fundament: Audit mit IP/User-Agent, Mail-Absender je Mandant

- Migration audit_request_context: ip_address, user_agent an audit_logs (Spec §26)
- writeAuditLog/writePlatformAudit erfassen IP (X-Forwarded-For) und User-Agent
  aus dem Request; außerhalb eines Requests (Worker/Skripte) null
- Audit-Viewer liest die neuen Spalten statt Heuristik aus before/after
- deliverMail nutzt Anzeigename und Reply-To aus TenantSettings (Spec §33.2);
  Absenderadresse bleibt Plattform-Domain (SPF/DKIM), Header-Injection bereinigt

Gate: tsc, lint, build, 24/24 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:20:41 +02:00
co-authored by Claude Opus 5
parent 2d1c07cf74
commit b8b8bddefe
5 changed files with 83 additions and 36 deletions
+29 -3
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/server/db";
import { getMailConfig, mailFrom } from "./config";
import { getMailConfig, mailFrom, type MailConfig } from "./config";
import { getMailProvider } from "./provider-smtp";
import { TransientMailError } from "./provider";
import { renderTemplate } from "./templates";
@@ -42,12 +42,13 @@ export async function deliverMail(job: MailJob): Promise<{ messageId: string }>
}
const rendered = renderTemplate(job.template, job.locale, job.vars);
const sender = await tenantSender(job.mailLogId, config);
try {
const { messageId } = await provider.send({
from: mailFrom(config),
from: sender.from,
to: job.to,
replyTo: config.replyTo,
replyTo: sender.replyTo,
subject: rendered.subject,
html: rendered.html,
text: rendered.text,
@@ -89,3 +90,28 @@ export async function markMailFailed(mailLogId: string, error: string): Promise<
data: { status: "failed", error: error.slice(0, 500) },
});
}
/**
* Tenant-specific display name and reply-to (spec §33.2). The sender ADDRESS always stays
* the platform address (SPF/DKIM alignment); only the display name and Reply-To vary.
* Raw client on purpose: the worker has no tenant context; the MailLog row carries the tenant.
*/
async function tenantSender(mailLogId: string, config: MailConfig): Promise<{ from: string; replyTo?: string }> {
const log = await prisma.mailLog.findUnique({ where: { id: mailLogId }, select: { tenantId: true } });
if (!log?.tenantId) return { from: mailFrom(config), replyTo: config.replyTo };
const settings = await prisma.tenantSettings.findUnique({
where: { tenantId: log.tenantId },
select: { mailFromName: true, mailReplyTo: true },
});
const name = sanitizeDisplayName(settings?.mailFromName);
return {
from: name ? mailFrom({ ...config, fromName: name }) : mailFrom(config),
replyTo: settings?.mailReplyTo?.trim() || config.replyTo,
};
}
/** Strip characters that could break the From header (quotes, angle brackets, CR/LF). */
function sanitizeDisplayName(v: string | null | undefined): string | null {
const cleaned = (v ?? "").replace(/[\r\n"<>]/g, "").trim().slice(0, 80);
return cleaned || null;
}