816 lines
24 KiB
TypeScript
816 lines
24 KiB
TypeScript
import nodemailer from "nodemailer";
|
|
import { getSettings } from "@/lib/settings";
|
|
import { DEFAULT_SETTINGS, SETTING_KEYS } from "@/lib/constants";
|
|
import {
|
|
DEFAULT_TIMEZONE,
|
|
formatDateDE,
|
|
formatTimeDE,
|
|
resolveTimeZone
|
|
} from "@/lib/date";
|
|
import { renderTemplate } from "@/lib/email/template-engine";
|
|
import { renderStyledEmail } from "@/lib/email/style-renderer";
|
|
import { normalizeMeetingButtonTemplate } from "@/lib/email/shortcodes";
|
|
import { withRetry } from "@/lib/services/retry";
|
|
import { buildPublicUrl } from "@/lib/public-url";
|
|
import {
|
|
reportDeliveryFailure,
|
|
resolveDeliveryIssues
|
|
} from "@/lib/services/delivery-issues";
|
|
|
|
type StaffRecipient = {
|
|
name: string;
|
|
email: string;
|
|
};
|
|
|
|
type ReminderKind = "primary" | "secondary";
|
|
|
|
type SmtpConfigInput = {
|
|
host?: string;
|
|
port?: string | number;
|
|
user?: string;
|
|
pass?: string;
|
|
fromName?: string;
|
|
from?: string;
|
|
secure?: boolean;
|
|
};
|
|
|
|
function sanitizeHeaderValue(value: string) {
|
|
return value.replace(/[\r\n]+/g, " ").trim();
|
|
}
|
|
|
|
function extractFromAddress(value?: string) {
|
|
if (!value) return "";
|
|
const match = value.match(/<([^>]+)>/);
|
|
if (match?.[1]) return sanitizeHeaderValue(match[1]);
|
|
return sanitizeHeaderValue(value);
|
|
}
|
|
|
|
function extractFromName(value?: string) {
|
|
if (!value) return "";
|
|
const match = value.match(/^([^<]+)<[^>]+>$/);
|
|
if (!match?.[1]) return "";
|
|
return sanitizeHeaderValue(match[1].replace(/^"(.+)"$/, "$1"));
|
|
}
|
|
|
|
function isEmail(value: string) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
}
|
|
|
|
function uniqueStaff(staffList: StaffRecipient[]): StaffRecipient[] {
|
|
const byEmail = new Map<string, StaffRecipient>();
|
|
for (const staff of staffList) {
|
|
if (!byEmail.has(staff.email)) {
|
|
byEmail.set(staff.email, staff);
|
|
}
|
|
}
|
|
return Array.from(byEmail.values());
|
|
}
|
|
|
|
function isRetryableMailError(error: unknown) {
|
|
const maybeCode =
|
|
typeof error === "object" && error !== null && "code" in error
|
|
? String((error as { code?: unknown }).code ?? "")
|
|
: "";
|
|
const code = maybeCode.toUpperCase();
|
|
|
|
if (["EAUTH", "EENVELOPE", "EADDRESS"].includes(code)) {
|
|
return false;
|
|
}
|
|
|
|
const message =
|
|
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
|
|
if (
|
|
message.includes("authentication") ||
|
|
message.includes("invalid login") ||
|
|
message.includes("bad credentials")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async function sendMailWithRetry(
|
|
transporter: nodemailer.Transporter,
|
|
mail: nodemailer.SendMailOptions,
|
|
meta: {
|
|
operation: string;
|
|
target: string;
|
|
}
|
|
) {
|
|
try {
|
|
await withRetry(
|
|
async () => transporter.sendMail(mail),
|
|
{
|
|
attempts: 4,
|
|
baseDelayMs: 700,
|
|
maxDelayMs: 5_000,
|
|
shouldRetry: isRetryableMailError
|
|
}
|
|
);
|
|
|
|
await resolveDeliveryIssues({
|
|
channel: "SMTP",
|
|
operation: meta.operation,
|
|
target: meta.target
|
|
});
|
|
} catch (error) {
|
|
await reportDeliveryFailure({
|
|
channel: "SMTP",
|
|
operation: meta.operation,
|
|
target: meta.target,
|
|
error
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function getTransportConfig(overrides?: SmtpConfigInput) {
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.SMTP_HOST,
|
|
SETTING_KEYS.SMTP_PORT,
|
|
SETTING_KEYS.SMTP_USER,
|
|
SETTING_KEYS.SMTP_PASS,
|
|
SETTING_KEYS.SMTP_FROM_NAME,
|
|
SETTING_KEYS.SMTP_FROM
|
|
]);
|
|
|
|
const host =
|
|
overrides && "host" in overrides
|
|
? overrides.host?.trim()
|
|
: settings[SETTING_KEYS.SMTP_HOST] || process.env.SMTP_HOST;
|
|
const portValue =
|
|
overrides && "port" in overrides
|
|
? overrides.port
|
|
: settings[SETTING_KEYS.SMTP_PORT] || process.env.SMTP_PORT || 587;
|
|
const parsedPort = Number(portValue || 587);
|
|
const port = Number.isFinite(parsedPort) ? parsedPort : 587;
|
|
const secureFromEnv =
|
|
(process.env.SMTP_SECURE ?? "").toLowerCase() === "true" ||
|
|
process.env.SMTP_SECURE === "1";
|
|
const secure =
|
|
overrides && "secure" in overrides && typeof overrides.secure === "boolean"
|
|
? overrides.secure
|
|
: secureFromEnv || port === 465;
|
|
const smtpUser =
|
|
overrides && "user" in overrides
|
|
? overrides.user?.trim() || ""
|
|
: settings[SETTING_KEYS.SMTP_USER] || process.env.SMTP_USER || "";
|
|
const smtpPass =
|
|
overrides && "pass" in overrides
|
|
? overrides.pass ?? ""
|
|
: settings[SETTING_KEYS.SMTP_PASS] || process.env.SMTP_PASS || "";
|
|
const smtpUserAddress = isEmail(smtpUser) ? smtpUser : "";
|
|
const rawFromAddress =
|
|
overrides && "from" in overrides
|
|
? overrides.from?.trim() || ""
|
|
: settings[SETTING_KEYS.SMTP_FROM] || process.env.SMTP_FROM || "";
|
|
const configuredFromAddress = extractFromAddress(rawFromAddress);
|
|
const fromAddress =
|
|
smtpUserAddress ||
|
|
(isEmail(configuredFromAddress) ? configuredFromAddress : "") ||
|
|
"no-reply@calbook.local";
|
|
const fromNameRaw =
|
|
overrides && "fromName" in overrides
|
|
? overrides.fromName || extractFromName(rawFromAddress) || "CalBook"
|
|
: settings[SETTING_KEYS.SMTP_FROM_NAME] ||
|
|
process.env.SMTP_FROM_NAME ||
|
|
extractFromName(rawFromAddress) ||
|
|
"CalBook";
|
|
const fromName = sanitizeHeaderValue(fromNameRaw) || "CalBook";
|
|
|
|
return {
|
|
host,
|
|
port,
|
|
secure,
|
|
auth:
|
|
smtpUser
|
|
? {
|
|
user: smtpUser,
|
|
pass: smtpPass
|
|
}
|
|
: undefined,
|
|
from: {
|
|
name: fromName,
|
|
address: fromAddress
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createTransporter(overrides?: SmtpConfigInput) {
|
|
const cfg = await getTransportConfig(overrides);
|
|
if (!cfg.host) return null;
|
|
return {
|
|
cfg,
|
|
transporter: nodemailer.createTransport(cfg)
|
|
};
|
|
}
|
|
|
|
export async function sendBookingEmails(params: {
|
|
appointment: {
|
|
customerEmail: string;
|
|
customerFirstName: string;
|
|
customerLastName: string;
|
|
customerPhone?: string | null;
|
|
notes?: string | null;
|
|
startAt: Date;
|
|
endAt: Date;
|
|
durationMinutes: number;
|
|
cancellationToken: string;
|
|
meetingUrl: string;
|
|
customerTimezone?: string;
|
|
};
|
|
staffList: StaffRecipient[];
|
|
companyName: string;
|
|
}) {
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM,
|
|
SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY,
|
|
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
|
SETTING_KEYS.COMPANY_NAME
|
|
]);
|
|
|
|
const transport = await createTransporter();
|
|
if (!transport) return;
|
|
const { cfg, transporter } = transport;
|
|
|
|
const staffRecipients = uniqueStaff(params.staffList);
|
|
if (staffRecipients.length === 0) return;
|
|
|
|
const customerName = `${params.appointment.customerFirstName} ${params.appointment.customerLastName}`;
|
|
const customerTimezone = resolveTimeZone(params.appointment.customerTimezone);
|
|
const customerDate = formatDateDE(params.appointment.startAt, false, customerTimezone);
|
|
const customerTime = formatTimeDE(params.appointment.startAt, customerTimezone);
|
|
const staffDate = formatDateDE(params.appointment.startAt, false, DEFAULT_TIMEZONE);
|
|
const staffTime = formatTimeDE(params.appointment.startAt, DEFAULT_TIMEZONE);
|
|
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
|
const cancelUrl = buildPublicUrl(
|
|
`/stornieren?token=${encodeURIComponent(params.appointment.cancellationToken)}`
|
|
);
|
|
const rescheduleUrl = buildPublicUrl(
|
|
`/buchen?rescheduleToken=${encodeURIComponent(params.appointment.cancellationToken)}`
|
|
);
|
|
const dashboardUrl = buildPublicUrl("/admin/termine");
|
|
|
|
const customerBaseValues: Record<string, string> = {
|
|
customerName,
|
|
date: customerDate,
|
|
time: customerTime,
|
|
duration: String(params.appointment.durationMinutes),
|
|
staffNames,
|
|
companyName: params.companyName,
|
|
cancelUrl,
|
|
rescheduleUrl,
|
|
phone: params.appointment.customerPhone ?? "-",
|
|
email: params.appointment.customerEmail,
|
|
notes: params.appointment.notes ?? "-",
|
|
meetingUrl: params.appointment.meetingUrl,
|
|
dashboardUrl,
|
|
staffName: staffRecipients[0]?.name ?? "Person",
|
|
timezone: customerTimezone
|
|
};
|
|
|
|
const staffBaseValues: Record<string, string> = {
|
|
...customerBaseValues,
|
|
date: staffDate,
|
|
time: staffTime,
|
|
timezone: DEFAULT_TIMEZONE
|
|
};
|
|
|
|
const customerSubject = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_CUSTOMER_CONFIRM],
|
|
customerBaseValues
|
|
);
|
|
const customerTemplate = normalizeMeetingButtonTemplate(
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_CUSTOMER_CONFIRM]
|
|
);
|
|
const customerBody = renderTemplate(
|
|
customerTemplate,
|
|
customerBaseValues
|
|
);
|
|
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
|
|
|
const customerStyled = renderStyledEmail({
|
|
styleId,
|
|
subject: customerSubject,
|
|
companyName: params.companyName,
|
|
heading: customerSubject,
|
|
body: customerBody,
|
|
ctaLabel: "Meeting beitreten",
|
|
ctaUrl: params.appointment.meetingUrl,
|
|
secondaryCtaLabel: "Termin stornieren",
|
|
secondaryCtaUrl: cancelUrl,
|
|
footerNote: ""
|
|
});
|
|
|
|
await sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: params.appointment.customerEmail,
|
|
subject: customerSubject,
|
|
text: customerStyled.text,
|
|
html: customerStyled.html
|
|
},
|
|
{
|
|
operation: "booking-customer",
|
|
target: params.appointment.customerEmail
|
|
}
|
|
);
|
|
|
|
await Promise.all(
|
|
staffRecipients.map((staff) => {
|
|
const values = {
|
|
...staffBaseValues,
|
|
staffName: staff.name
|
|
};
|
|
const staffBody = renderTemplate(
|
|
normalizeMeetingButtonTemplate(settings[SETTING_KEYS.EMAIL_TEMPLATE_STAFF_NOTIFY]),
|
|
values
|
|
);
|
|
const staffSubject = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_STAFF_NOTIFY],
|
|
values
|
|
);
|
|
const staffStyled = renderStyledEmail({
|
|
styleId,
|
|
subject: staffSubject,
|
|
companyName: params.companyName,
|
|
heading: staffSubject,
|
|
body: staffBody,
|
|
ctaLabel: "Meeting beitreten",
|
|
ctaUrl: params.appointment.meetingUrl,
|
|
secondaryCtaLabel: "Termin stornieren",
|
|
secondaryCtaUrl: cancelUrl,
|
|
footerNote: ""
|
|
});
|
|
|
|
return sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: staff.email,
|
|
subject: staffSubject,
|
|
text: staffStyled.text,
|
|
html: staffStyled.html
|
|
},
|
|
{
|
|
operation: "booking-staff",
|
|
target: staff.email
|
|
}
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
export async function sendCancellationEmails(params: {
|
|
customerEmail: string;
|
|
customerName: string;
|
|
staffList: StaffRecipient[];
|
|
date: Date;
|
|
customerTimezone?: string;
|
|
companyName: string;
|
|
}) {
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER,
|
|
SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF,
|
|
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID
|
|
]);
|
|
const transport = await createTransporter();
|
|
if (!transport) return;
|
|
const { cfg, transporter } = transport;
|
|
|
|
const staffRecipients = uniqueStaff(params.staffList);
|
|
|
|
const customerTimezone = resolveTimeZone(params.customerTimezone);
|
|
const customerDate = formatDateDE(params.date, false, customerTimezone);
|
|
const customerTime = formatTimeDE(params.date, customerTimezone);
|
|
const staffDate = formatDateDE(params.date, false, DEFAULT_TIMEZONE);
|
|
const staffTime = formatTimeDE(params.date, DEFAULT_TIMEZONE);
|
|
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
|
|
|
const customerValues: Record<string, string> = {
|
|
customerName: params.customerName,
|
|
date: customerDate,
|
|
time: customerTime,
|
|
companyName: params.companyName,
|
|
staffNames,
|
|
staffName: staffRecipients[0]?.name ?? "Person",
|
|
timezone: customerTimezone
|
|
};
|
|
const staffValues: Record<string, string> = {
|
|
...customerValues,
|
|
date: staffDate,
|
|
time: staffTime,
|
|
timezone: DEFAULT_TIMEZONE
|
|
};
|
|
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
|
const cancellationBody = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_CUSTOMER] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION],
|
|
customerValues
|
|
);
|
|
const cancellationCustomerSubject = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_CUSTOMER],
|
|
customerValues
|
|
);
|
|
const cancellationCustomerStyled = renderStyledEmail({
|
|
styleId,
|
|
subject: cancellationCustomerSubject,
|
|
companyName: params.companyName,
|
|
heading: cancellationCustomerSubject,
|
|
body: cancellationBody,
|
|
footerNote: ""
|
|
});
|
|
|
|
await sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: params.customerEmail,
|
|
subject: cancellationCustomerSubject,
|
|
text: cancellationCustomerStyled.text,
|
|
html: cancellationCustomerStyled.html
|
|
},
|
|
{
|
|
operation: "cancel-customer",
|
|
target: params.customerEmail
|
|
}
|
|
);
|
|
|
|
await Promise.all(
|
|
staffRecipients.map((staff) => {
|
|
const values = { ...staffValues, staffName: staff.name };
|
|
const subject = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_CANCELLATION_STAFF],
|
|
values
|
|
);
|
|
const body = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION_STAFF] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_CANCELLATION],
|
|
values
|
|
);
|
|
const styled = renderStyledEmail({
|
|
styleId,
|
|
subject,
|
|
companyName: params.companyName,
|
|
heading: subject,
|
|
body,
|
|
footerNote: ""
|
|
});
|
|
|
|
return sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: staff.email,
|
|
subject,
|
|
text: styled.text,
|
|
html: styled.html
|
|
},
|
|
{
|
|
operation: "cancel-staff",
|
|
target: staff.email
|
|
}
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
export async function sendReminderEmails(params: {
|
|
customerEmail: string;
|
|
customerName: string;
|
|
customerPhone?: string | null;
|
|
notes?: string | null;
|
|
staffList: StaffRecipient[];
|
|
date: Date;
|
|
customerTimezone?: string;
|
|
durationMinutes: number;
|
|
cancellationToken: string;
|
|
meetingUrl: string;
|
|
companyName: string;
|
|
reminderKind: ReminderKind;
|
|
hoursBefore: number;
|
|
}) {
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY,
|
|
SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY
|
|
]);
|
|
const transport = await createTransporter();
|
|
if (!transport) return;
|
|
const { cfg, transporter } = transport;
|
|
|
|
const staffRecipients = uniqueStaff(params.staffList);
|
|
if (staffRecipients.length === 0) return;
|
|
const customerTimezone = resolveTimeZone(params.customerTimezone);
|
|
const customerDate = formatDateDE(params.date, false, customerTimezone);
|
|
const customerTime = formatTimeDE(params.date, customerTimezone);
|
|
const staffDate = formatDateDE(params.date, false, DEFAULT_TIMEZONE);
|
|
const staffTime = formatTimeDE(params.date, DEFAULT_TIMEZONE);
|
|
const staffNames = staffRecipients.map((staff) => staff.name).join(", ");
|
|
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
|
const reminderHours = Math.max(1, Math.floor(params.hoursBefore));
|
|
const reminderLabel =
|
|
reminderHours === 1 ? "in 1 Stunde" : `in ${reminderHours} Stunden`;
|
|
const cancelUrl = buildPublicUrl(
|
|
`/stornieren?token=${encodeURIComponent(params.cancellationToken)}`
|
|
);
|
|
const rescheduleUrl = buildPublicUrl(
|
|
`/buchen?rescheduleToken=${encodeURIComponent(params.cancellationToken)}`
|
|
);
|
|
const dashboardUrl = buildPublicUrl("/admin/termine");
|
|
|
|
const customerBaseValues: Record<string, string> = {
|
|
customerName: params.customerName,
|
|
date: customerDate,
|
|
time: customerTime,
|
|
duration: String(params.durationMinutes),
|
|
staffNames,
|
|
companyName: params.companyName,
|
|
cancelUrl,
|
|
rescheduleUrl,
|
|
phone: params.customerPhone?.trim() || "-",
|
|
email: params.customerEmail,
|
|
notes: params.notes?.trim() || "-",
|
|
meetingUrl: params.meetingUrl,
|
|
dashboardUrl,
|
|
staffName: staffRecipients[0]?.name ?? "Person",
|
|
timezone: customerTimezone,
|
|
reminderLabel,
|
|
hoursBefore: String(reminderHours),
|
|
reminderKind: params.reminderKind
|
|
};
|
|
const staffBaseValues: Record<string, string> = {
|
|
...customerBaseValues,
|
|
date: staffDate,
|
|
time: staffTime,
|
|
timezone: DEFAULT_TIMEZONE
|
|
};
|
|
|
|
const customerSubjectTemplate =
|
|
params.reminderKind === "secondary"
|
|
? settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_SECONDARY] ||
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER]
|
|
: settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER_PRIMARY] ||
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_CUSTOMER];
|
|
const customerBodyTemplate =
|
|
params.reminderKind === "secondary"
|
|
? settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_SECONDARY] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER]
|
|
: settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER_PRIMARY] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_CUSTOMER];
|
|
|
|
const subjectCustomer = renderTemplate(customerSubjectTemplate, customerBaseValues);
|
|
const customerBody = renderTemplate(
|
|
normalizeMeetingButtonTemplate(customerBodyTemplate),
|
|
customerBaseValues
|
|
);
|
|
|
|
const customerStyled = renderStyledEmail({
|
|
styleId,
|
|
subject: subjectCustomer,
|
|
companyName: params.companyName,
|
|
heading: subjectCustomer,
|
|
lead: `Dein Termin startet ${reminderLabel}.`,
|
|
body: customerBody,
|
|
ctaLabel: "Meeting beitreten",
|
|
ctaUrl: params.meetingUrl,
|
|
secondaryCtaLabel: "Termin stornieren",
|
|
secondaryCtaUrl: cancelUrl,
|
|
footerNote: ""
|
|
});
|
|
|
|
await sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: params.customerEmail,
|
|
subject: subjectCustomer,
|
|
text: customerStyled.text,
|
|
html: customerStyled.html
|
|
},
|
|
{
|
|
operation: `reminder-${params.reminderKind}-customer`,
|
|
target: params.customerEmail
|
|
}
|
|
);
|
|
|
|
await Promise.all(
|
|
staffRecipients.map((staff) => {
|
|
const values = {
|
|
...staffBaseValues,
|
|
staffName: staff.name
|
|
};
|
|
const staffSubjectTemplate =
|
|
params.reminderKind === "secondary"
|
|
? settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_SECONDARY] ||
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF]
|
|
: settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF_PRIMARY] ||
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_REMINDER_STAFF];
|
|
const staffBodyTemplate =
|
|
params.reminderKind === "secondary"
|
|
? settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_SECONDARY] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF]
|
|
: settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF_PRIMARY] ||
|
|
settings[SETTING_KEYS.EMAIL_TEMPLATE_REMINDER_STAFF];
|
|
|
|
const subjectStaff = renderTemplate(staffSubjectTemplate, values);
|
|
const staffBody = renderTemplate(
|
|
normalizeMeetingButtonTemplate(staffBodyTemplate),
|
|
values
|
|
);
|
|
const staffStyled = renderStyledEmail({
|
|
styleId,
|
|
subject: subjectStaff,
|
|
companyName: params.companyName,
|
|
heading: subjectStaff,
|
|
lead: `Der Termin mit ${params.customerName} startet ${reminderLabel}.`,
|
|
body: staffBody,
|
|
ctaLabel: "Meeting beitreten",
|
|
ctaUrl: params.meetingUrl,
|
|
secondaryCtaLabel: "Termin stornieren",
|
|
secondaryCtaUrl: cancelUrl,
|
|
footerNote: ""
|
|
});
|
|
|
|
return sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: staff.email,
|
|
subject: subjectStaff,
|
|
text: staffStyled.text,
|
|
html: staffStyled.html
|
|
},
|
|
{
|
|
operation: `reminder-${params.reminderKind}-staff`,
|
|
target: staff.email
|
|
}
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
export async function sendInstantMeetingEmails(params: {
|
|
recipients: Array<{ email: string; name?: string }>;
|
|
meetingUrl: string;
|
|
scopeLabel: string;
|
|
initiatorName: string;
|
|
companyName: string;
|
|
customMessage?: string;
|
|
subjectOverride?: string;
|
|
}) {
|
|
const transport = await createTransporter();
|
|
if (!transport) {
|
|
return { ok: false as const, message: "SMTP ist nicht konfiguriert." };
|
|
}
|
|
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
|
SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT,
|
|
SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE
|
|
]);
|
|
|
|
const { cfg, transporter } = transport;
|
|
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
|
const subjectTemplate =
|
|
params.subjectOverride?.trim() ||
|
|
settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT] ||
|
|
DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_SUBJECT];
|
|
const bodyTemplate =
|
|
settings[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE] ||
|
|
DEFAULT_SETTINGS[SETTING_KEYS.INSTANT_MEETING_EMAIL_TEMPLATE];
|
|
|
|
const recipients = uniqueStaff(
|
|
params.recipients
|
|
.map((recipient) => ({
|
|
name: recipient.name?.trim() || recipient.email,
|
|
email: recipient.email.trim().toLowerCase()
|
|
}))
|
|
.filter((recipient) => isEmail(recipient.email))
|
|
);
|
|
|
|
if (recipients.length === 0) {
|
|
return { ok: false as const, message: "Keine gültigen Empfänger gefunden." };
|
|
}
|
|
|
|
await Promise.all(
|
|
recipients.map(async (recipient) => {
|
|
const values: Record<string, string> = {
|
|
recipientName: recipient.name || recipient.email,
|
|
companyName: params.companyName,
|
|
meetingUrl: params.meetingUrl,
|
|
scopeLabel: params.scopeLabel,
|
|
initiatorName: params.initiatorName,
|
|
customMessage: params.customMessage?.trim() || ""
|
|
};
|
|
|
|
const subject = renderTemplate(subjectTemplate, values);
|
|
const body = renderTemplate(bodyTemplate, values);
|
|
const styled = renderStyledEmail({
|
|
styleId,
|
|
subject,
|
|
companyName: params.companyName,
|
|
heading: subject,
|
|
body,
|
|
ctaLabel: "Meeting beitreten",
|
|
ctaUrl: params.meetingUrl,
|
|
footerNote: ""
|
|
});
|
|
|
|
await sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: recipient.email,
|
|
subject,
|
|
text: styled.text,
|
|
html: styled.html
|
|
},
|
|
{
|
|
operation: "instant-meeting",
|
|
target: recipient.email
|
|
}
|
|
);
|
|
})
|
|
);
|
|
|
|
return { ok: true as const, sentCount: recipients.length };
|
|
}
|
|
|
|
export async function sendSmtpTestEmail(params: {
|
|
to: string;
|
|
companyName: string;
|
|
smtp?: SmtpConfigInput;
|
|
}) {
|
|
const settings = await getSettings([
|
|
SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID,
|
|
SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST,
|
|
SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST
|
|
]);
|
|
const transport = await createTransporter(params.smtp);
|
|
if (!transport) {
|
|
return { ok: false as const, message: "SMTP ist nicht konfiguriert." };
|
|
}
|
|
|
|
const { cfg, transporter } = transport;
|
|
const styleId = settings[SETTING_KEYS.EMAIL_STYLE_TEMPLATE_ID];
|
|
const values: Record<string, string> = {
|
|
companyName: params.companyName,
|
|
recipientEmail: params.to,
|
|
email: params.to,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
const subject = renderTemplate(
|
|
settings[SETTING_KEYS.EMAIL_SUBJECT_SMTP_TEST],
|
|
values
|
|
);
|
|
const body = renderTemplate(settings[SETTING_KEYS.EMAIL_TEMPLATE_SMTP_TEST], values);
|
|
const styled = renderStyledEmail({
|
|
styleId,
|
|
subject,
|
|
companyName: params.companyName,
|
|
heading: subject,
|
|
body,
|
|
footerNote: ""
|
|
});
|
|
|
|
try {
|
|
await sendMailWithRetry(
|
|
transporter,
|
|
{
|
|
from: cfg.from,
|
|
to: params.to,
|
|
subject,
|
|
text: styled.text,
|
|
html: styled.html
|
|
},
|
|
{
|
|
operation: "smtp-test",
|
|
target: params.to
|
|
}
|
|
);
|
|
return { ok: true as const };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "SMTP-Test fehlgeschlagen";
|
|
return { ok: false as const, message };
|
|
}
|
|
}
|