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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user