Improve email branding and SMTP envelope
CI / test (push) Canceled after 0s
Release / promote-and-deploy (push) Canceled after 0s
CI / build-image (push) Canceled after 0s

This commit is contained in:
2026-09-18 12:51:40 +02:00
parent ad353190f3
commit 7edeafe7c3
10 changed files with 67 additions and 47 deletions
+2 -1
View File
@@ -57,7 +57,8 @@ SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM="ANOUMA <support@anouma.org>" # Mailbox address used by the SMTP relay. The app displays ANOUMA as the name.
SMTP_FROM=support@anouma.org
# Keep true for publicly trusted SMTP certificates. Set false only when the # Keep true for publicly trusted SMTP certificates. Set false only when the
# configured SMTP proxy intentionally uses a private/self-signed certificate. # configured SMTP proxy intentionally uses a private/self-signed certificate.
SMTP_TLS_REJECT_UNAUTHORIZED=true SMTP_TLS_REJECT_UNAUTHORIZED=true
+4 -10
View File
@@ -55,13 +55,7 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx - name: Build image
uses: docker/setup-buildx-action@v3 run: docker build --tag "$IMAGE:$GITHUB_SHA" .
- name: Build and push image - name: Push image
uses: docker/build-push-action@v6 run: docker push "$IMAGE:$GITHUB_SHA"
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
cache-from: type=gha,scope=anouma-image
cache-to: type=gha,mode=max,scope=anouma-image
+1 -1
View File
@@ -70,7 +70,7 @@ export function bookingConfirmedEmail(
extras.push(renderButton("Meeting betreten", args.online.joinUrl)); extras.push(renderButton("Meeting betreten", args.online.joinUrl));
extras.push( extras.push(
`<p style="margin:0 0 4px;color:#66505f;font-size:14px;"><strong>Meeting-Passwort:</strong></p>` + `<p style="margin:0 0 4px;color:#66505f;font-size:14px;"><strong>Meeting-Passwort:</strong></p>` +
`<p style="margin:0 0 20px;color:#66505f;font-size:20px;letter-spacing:0.08em;font-family:'Courier New',monospace;">${escapeHtml(args.online.password)}</p>`, `<p style="margin:0 0 20px;color:#66505f;font-size:20px;letter-spacing:0.08em;font-family:'Antropos',Georgia,'Times New Roman',serif;">${escapeHtml(args.online.password)}</p>`,
); );
} }
if (args.onsite) { if (args.onsite) {
+18 -11
View File
@@ -5,6 +5,8 @@ const colors = {
creamLight: "#fbf6f1", creamLight: "#fbf6f1",
}; };
const emailFont = "'Antropos', Georgia, 'Times New Roman', serif";
export function escapeHtml(value: string): string { export function escapeHtml(value: string): string {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -17,7 +19,7 @@ export function renderButton(label: string, href: string): string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px auto;"> return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px auto;">
<tr> <tr>
<td style="border-radius:999px;background:${colors.mauveDark};"> <td style="border-radius:999px;background:${colors.mauveDark};">
<a href="${href}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.02em;"> <a href="${href}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-family:${emailFont};font-size:14px;font-weight:600;letter-spacing:0.02em;">
${escapeHtml(label)} ${escapeHtml(label)}
</a> </a>
</td> </td>
@@ -28,7 +30,7 @@ export function renderButton(label: string, href: string): string {
export function renderFactBox(rows: { label: string; value: string }[]): string { export function renderFactBox(rows: { label: string; value: string }[]): string {
const cells = rows const cells = rows
.map( .map(
(row, i) => `<p style="margin:0 ${i === rows.length - 1 ? "0" : "0 12px"} 0;color:${colors.plum};font-size:14px;"> (row, i) => `<p style="margin:0 ${i === rows.length - 1 ? "0" : "0 12px"} 0;color:${colors.plum};font-family:${emailFont};font-size:14px;">
<strong>${escapeHtml(row.label)}:</strong><br />${escapeHtml(row.value)} <strong>${escapeHtml(row.label)}:</strong><br />${escapeHtml(row.value)}
</p>`, </p>`,
) )
@@ -45,24 +47,29 @@ export function renderEmailShell(args: { preheader?: string; bodyHtml: string; f
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:Georgia,'Times New Roman',serif;"> <head>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;"> <style>
@font-face { font-family: 'Antropos'; src: url('https://anouma.org/fonts/baar-antropos.ttf') format('truetype'); font-style: normal; font-weight: 100 900; }
</style>
</head>
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:${emailFont};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;font-family:${emailFont};">
<tr> <tr>
<td align="center"> <td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;font-family:${emailFont};">
<tr> <tr>
<td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;"> <td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;font-family:${emailFont};">
<span style="color:#ffffff;font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span> <span style="color:#ffffff;font-family:${emailFont};font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:32px;"> <td style="padding:32px;font-family:${emailFont};">
${bodyHtml} ${bodyHtml}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;"> <td style="padding:20px 32px;background:${colors.cream};text-align:center;font-family:${emailFont};">
<span style="color:${colors.plum};font-size:12px;">${escapeHtml(footerText)}</span> <span style="color:${colors.plum};font-family:${emailFont};font-size:12px;">${escapeHtml(footerText)}</span>
</td> </td>
</tr> </tr>
</table> </table>
@@ -76,5 +83,5 @@ export function renderEmailShell(args: { preheader?: string; bodyHtml: string; f
export function paragraph(text: string, opts: { italic?: boolean; small?: boolean } = {}): string { export function paragraph(text: string, opts: { italic?: boolean; small?: boolean } = {}): string {
const size = opts.small ? "13px" : "16px"; const size = opts.small ? "13px" : "16px";
const style = opts.italic ? "font-style:italic;" : ""; const style = opts.italic ? "font-style:italic;" : "";
return `<p style="margin:0 0 20px;color:${colors.plum};font-size:${size};line-height:1.6;${style}">${escapeHtml(text)}</p>`; return `<p style="margin:0 0 20px;color:${colors.plum};font-family:${emailFont};font-size:${size};line-height:1.6;${style}">${escapeHtml(text)}</p>`;
} }
+3 -2
View File
@@ -1,5 +1,5 @@
import type { PayloadEmailAdapter } from "payload"; import type { PayloadEmailAdapter } from "payload";
import { getMailFrom, getMailTransport } from "./transport"; import { getMailEnvelopeFrom, getMailFrom, getMailTransport } from "./transport";
/** Connects Payload's built-in auth emails to the same SMTP transport as ANOUMA's own emails. */ /** Connects Payload's built-in auth emails to the same SMTP transport as ANOUMA's own emails. */
export const payloadEmailAdapter: PayloadEmailAdapter = () => ({ export const payloadEmailAdapter: PayloadEmailAdapter = () => ({
@@ -9,7 +9,8 @@ export const payloadEmailAdapter: PayloadEmailAdapter = () => ({
sendEmail: async (message) => { sendEmail: async (message) => {
return getMailTransport().sendMail({ return getMailTransport().sendMail({
...message, ...message,
from: message.from || getMailFrom(), from: getMailFrom(),
envelope: { from: getMailEnvelopeFrom() },
}); });
}, },
}); });
+23 -18
View File
@@ -21,34 +21,39 @@ export function renderReminderEmailHtml(data: ReminderEmailData): string {
return `<!doctype html> return `<!doctype html>
<html lang="de"> <html lang="de">
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:Georgia,'Times New Roman',serif;"> <head>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;"> <style>
@font-face { font-family: 'Antropos'; src: url('https://anouma.org/fonts/baar-antropos.ttf') format('truetype'); font-style: normal; font-weight: 100 900; }
</style>
</head>
<body style="margin:0;padding:0;background:${colors.creamLight};font-family:'Antropos',Georgia,'Times New Roman',serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.creamLight};padding:32px 16px;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<tr> <tr>
<td align="center"> <td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:24px;overflow:hidden;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<tr> <tr>
<td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;"> <td style="background:${colors.mauveDark};padding:28px 32px;text-align:center;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<span style="color:#ffffff;font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span> <span style="color:#ffffff;font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:22px;letter-spacing:0.12em;font-weight:600;">ANOUMA</span>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:32px;"> <td style="padding:32px;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<p style="margin:0 0 20px;color:${colors.plum};font-size:16px;line-height:1.6;"> <p style="margin:0 0 20px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:16px;line-height:1.6;">
Hallo ${escapeHtml(recipientName)}, Hallo ${escapeHtml(recipientName)},
</p> </p>
<p style="margin:0 0 24px;color:${colors.plum};font-size:18px;line-height:1.6;font-style:italic;"> <p style="margin:0 0 24px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:18px;line-height:1.6;font-style:italic;">
Dein Online-Termin bei ANOUMA beginnt in ${minutesBefore} Minuten. Dein Online-Termin bei ANOUMA beginnt in ${minutesBefore} Minuten.
</p> </p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.cream};border-radius:16px;"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${colors.cream};border-radius:16px;">
<tr> <tr>
<td style="padding:20px 24px;"> <td style="padding:20px 24px;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<p style="margin:0 0 12px;color:${colors.plum};font-size:14px;"> <p style="margin:0 0 12px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:14px;">
<strong>Termin:</strong><br />${escapeHtml(eventTitle)} <strong>Termin:</strong><br />${escapeHtml(eventTitle)}
</p> </p>
<p style="margin:0 0 12px;color:${colors.plum};font-size:14px;"> <p style="margin:0 0 12px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:14px;">
<strong>Datum:</strong><br />${escapeHtml(dateLabel)} <strong>Datum:</strong><br />${escapeHtml(dateLabel)}
</p> </p>
<p style="margin:0;color:${colors.plum};font-size:14px;"> <p style="margin:0;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:14px;">
<strong>Uhrzeit:</strong><br />${escapeHtml(timeLabel)} Uhr <strong>Uhrzeit:</strong><br />${escapeHtml(timeLabel)} Uhr
</p> </p>
</td> </td>
@@ -57,26 +62,26 @@ export function renderReminderEmailHtml(data: ReminderEmailData): string {
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px auto;"> <table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px auto;">
<tr> <tr>
<td style="border-radius:999px;background:${colors.mauveDark};"> <td style="border-radius:999px;background:${colors.mauveDark};">
<a href="${joinUrl}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.02em;"> <a href="${joinUrl}" style="display:inline-block;padding:14px 32px;color:#ffffff;text-decoration:none;font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:14px;font-weight:600;letter-spacing:0.02em;">
Meeting betreten Meeting betreten
</a> </a>
</td> </td>
</tr> </tr>
</table> </table>
<p style="margin:0 0 4px;color:${colors.plum};font-size:14px;"> <p style="margin:0 0 4px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:14px;">
<strong>Meeting-Passwort:</strong> <strong>Meeting-Passwort:</strong>
</p> </p>
<p style="margin:0 0 20px;color:${colors.plum};font-size:20px;letter-spacing:0.08em;font-family:'Courier New',monospace;"> <p style="margin:0 0 20px;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:20px;letter-spacing:0.08em;">
${escapeHtml(meetingPassword)} ${escapeHtml(meetingPassword)}
</p> </p>
<p style="margin:0;color:${colors.plum};font-size:13px;line-height:1.6;"> <p style="margin:0;color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:13px;line-height:1.6;">
Bitte halte dein Passwort bereit. Beim Betreten des Meetings wirst du außerdem nach deinem Namen gefragt. Bitte halte dein Passwort bereit. Beim Betreten des Meetings wirst du außerdem nach deinem Namen gefragt.
</p> </p>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;"> <td style="padding:20px 32px;background:${colors.cream};text-align:center;font-family:'Antropos',Georgia,'Times New Roman',serif;">
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung - mit dir selbst, miteinander und mit der Natur.</span> <span style="color:${colors.plum};font-family:'Antropos',Georgia,'Times New Roman',serif;font-size:12px;">Räume für Verbindung - mit dir selbst, miteinander und mit der Natur.</span>
</td> </td>
</tr> </tr>
</table> </table>
+2 -1
View File
@@ -1,4 +1,4 @@
import { getMailFrom, getMailTransport } from "./transport"; import { getMailEnvelopeFrom, getMailFrom, getMailTransport } from "./transport";
export async function sendEmail( export async function sendEmail(
to: string, to: string,
@@ -8,6 +8,7 @@ export async function sendEmail(
const transport = getMailTransport(); const transport = getMailTransport();
await transport.sendMail({ await transport.sendMail({
from: getMailFrom(), from: getMailFrom(),
envelope: { from: getMailEnvelopeFrom() },
to, to,
subject: template.subject, subject: template.subject,
html: template.html, html: template.html,
+2 -1
View File
@@ -1,10 +1,11 @@
import { getMailFrom, getMailTransport } from "./transport"; import { getMailEnvelopeFrom, getMailFrom, getMailTransport } from "./transport";
import { renderReminderEmailHtml, renderReminderEmailText, type ReminderEmailData } from "./reminderTemplate"; import { renderReminderEmailHtml, renderReminderEmailText, type ReminderEmailData } from "./reminderTemplate";
export async function sendReminderEmail(to: string, data: ReminderEmailData) { export async function sendReminderEmail(to: string, data: ReminderEmailData) {
const transport = getMailTransport(); const transport = getMailTransport();
await transport.sendMail({ await transport.sendMail({
from: getMailFrom(), from: getMailFrom(),
envelope: { from: getMailEnvelopeFrom() },
to, to,
subject: `Dein Online-Termin bei ANOUMA beginnt in ${data.minutesBefore} Minuten`, subject: `Dein Online-Termin bei ANOUMA beginnt in ${data.minutesBefore} Minuten`,
html: renderReminderEmailHtml(data), html: renderReminderEmailHtml(data),
+11 -1
View File
@@ -30,5 +30,15 @@ export function getMailTransport(): nodemailer.Transporter {
} }
export function getMailFrom(): string { export function getMailFrom(): string {
return process.env.SMTP_FROM || "ANOUMA <support@anouma.org>"; const configured = (process.env.SMTP_FROM || process.env.SMTP_USER || "support@anouma.org").trim();
const unquoted = configured.replace(/^("|')(.*)\1$/, "$2").trim();
// Keep the address in the message header, but show only ANOUMA in mail clients.
if (unquoted.includes("<") && unquoted.includes(">")) return unquoted;
return `ANOUMA <${unquoted}>`;
}
/** Envelope sender used by strict SMTP relays for sender ownership checks. */
export function getMailEnvelopeFrom(): string {
return process.env.SMTP_USER || "support@anouma.org";
} }
+1 -1
View File
@@ -353,7 +353,7 @@ if [ -z "$(env_get NEXT_PUBLIC_SERVER_URL)" ]; then
DOMAIN="$(ask "Domain" "anouma.org" "DOMAIN")" DOMAIN="$(ask "Domain" "anouma.org" "DOMAIN")"
APP_URL="$(ask "Application URL" "https://$DOMAIN" "APP_URL")" APP_URL="$(ask "Application URL" "https://$DOMAIN" "APP_URL")"
env_set NEXT_PUBLIC_SERVER_URL "$APP_URL" env_set NEXT_PUBLIC_SERVER_URL "$APP_URL"
[ -z "$(env_get SMTP_FROM)" ] && env_set SMTP_FROM "ANOUMA <no-reply@${DOMAIN}>" [ -z "$(env_get SMTP_FROM)" ] && env_set SMTP_FROM "no-reply@${DOMAIN}"
else else
log_info "NEXT_PUBLIC_SERVER_URL bereits vorhanden - unverändert." log_info "NEXT_PUBLIC_SERVER_URL bereits vorhanden - unverändert."
fi fi