Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7edeafe7c3 | ||
|
|
ad353190f3 | ||
|
|
82a89cd3d9 | ||
|
|
46a7cc23bd |
+5
-1
@@ -57,7 +57,11 @@ 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
|
||||||
|
# configured SMTP proxy intentionally uses a private/self-signed certificate.
|
||||||
|
SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||||
|
|
||||||
# Address used as Reply-To for CMS replies. Configure this mailbox with an
|
# Address used as Reply-To for CMS replies. Configure this mailbox with an
|
||||||
# inbound provider/webhook that POSTs replies to /api/email/inbound.
|
# inbound provider/webhook that POSTs replies to /api/email/inbound.
|
||||||
|
|||||||
@@ -35,16 +35,13 @@ jobs:
|
|||||||
run: npx next typegen && npx tsc --noEmit
|
run: npx next typegen && npx tsc --noEmit
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: npm test
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
build-image:
|
build-image:
|
||||||
needs: test
|
needs: test
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: write
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
env:
|
env:
|
||||||
@@ -59,6 +56,6 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: docker build -t "$IMAGE:$GITHUB_SHA" .
|
run: docker build --tag "$IMAGE:$GITHUB_SHA" .
|
||||||
- name: Push image
|
- name: Push image
|
||||||
run: docker push "$IMAGE:$GITHUB_SHA"
|
run: docker push "$IMAGE:$GITHUB_SHA"
|
||||||
|
|||||||
+1
-1
@@ -193,7 +193,7 @@ See `.env.example` for the full, commented list. `setup.sh` writes the real file
|
|||||||
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
|
| `TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile site key |
|
||||||
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
|
| `TURNSTILE_SECRET_KEY` | Server-only Cloudflare Turnstile secret; keep it in `/opt/anouma/.env` and never in GitHub Actions or the repository |
|
||||||
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
|
| `TURNSTILE_HOSTNAMES` | Hostnames accepted by Siteverify; production is `anouma.org` only (local development may use `localhost,127.0.0.1`) |
|
||||||
| `SMTP_*` | The existing ANOUMA mail system |
|
| `SMTP_*` | The existing ANOUMA mail system. `SMTP_TLS_REJECT_UNAUTHORIZED` stays `true` unless the configured SMTP proxy intentionally uses a private/self-signed certificate; only then set it to `false`. |
|
||||||
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
|
| `INBOUND_REPLY_TO`, `INBOUND_EMAIL_SECRET` | Mailbox and shared secret for the inbound email webhook (`/api/email/inbound`) |
|
||||||
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
|
| `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | eigener LiveKit-SFU und serverseitige Token-Erzeugung |
|
||||||
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
| `DOCKER_NETWORK`, `NPM_NETWORK` | Docker network names |
|
||||||
|
|||||||
+6
-2
@@ -5,10 +5,10 @@
|
|||||||
# .env.example) - this image builds without a database connection.
|
# .env.example) - this image builds without a database connection.
|
||||||
|
|
||||||
FROM node:22-alpine AS base
|
FROM node:22-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat su-exec
|
||||||
|
|
||||||
# ---- deps: install once, cached as its own layer -------------------------
|
# ---- deps: install once, cached as its own layer -------------------------
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -41,7 +41,11 @@ COPY --from=builder --chown=nextjs:nodejs /app /app
|
|||||||
# Docker volumes mounted over these paths (see docker-compose.yml).
|
# Docker volumes mounted over these paths (see docker-compose.yml).
|
||||||
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
RUN mkdir -p media backups && chown -R nextjs:nodejs media backups
|
||||||
|
|
||||||
USER nextjs
|
COPY --from=builder --chown=root:root /app/docker/anouma-entrypoint.sh /usr/local/bin/anouma-entrypoint.sh
|
||||||
|
RUN chmod 755 /usr/local/bin/anouma-entrypoint.sh
|
||||||
|
|
||||||
|
USER root
|
||||||
|
ENTRYPOINT ["/usr/local/bin/anouma-entrypoint.sh"]
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ deploy_version() {
|
|||||||
registry_login
|
registry_login
|
||||||
log_info "Pulling ${ANOUMA_IMAGE} …"
|
log_info "Pulling ${ANOUMA_IMAGE} …"
|
||||||
$COMPOSE pull app || return 1
|
$COMPOSE pull app || return 1
|
||||||
|
# The media directory is a bind mount. Its host ownership can differ from
|
||||||
|
# the unprivileged `nextjs` user inside the image, which otherwise turns a
|
||||||
|
# valid image upload into Payload's generic FileUploadError. The deploy user
|
||||||
|
# already controls Docker, so use the image's root user only for this narrow
|
||||||
|
# ownership repair before starting the app.
|
||||||
|
log_info "Preparing media storage permissions …"
|
||||||
|
$COMPOSE run --rm --user root --entrypoint /bin/sh app -c \
|
||||||
|
'mkdir -p /app/media && chown -R 1001:1001 /app/media && chmod 775 /app/media' || return 1
|
||||||
log_info "Running database migrations …"
|
log_info "Running database migrations …"
|
||||||
$COMPOSE run --rm app npm run migrate || return 1
|
$COMPOSE run --rm app npm run migrate || return 1
|
||||||
log_info "Starting containers …"
|
log_info "Starting containers …"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# /app/media is a persistent host bind mount in production. Its host owner
|
||||||
|
# can differ from the unprivileged application user inside the image, so fix
|
||||||
|
# the mount ownership before dropping privileges for the actual app process.
|
||||||
|
if [ -d /app/media ]; then
|
||||||
|
chown -R nextjs:nodejs /app/media
|
||||||
|
chmod 775 /app/media
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec su-exec nextjs "$@"
|
||||||
@@ -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
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
+17
-1
@@ -15,6 +15,12 @@ export function getMailTransport(): nodemailer.Transporter {
|
|||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: Number(SMTP_PORT),
|
port: Number(SMTP_PORT),
|
||||||
secure: Number(SMTP_PORT) === 465,
|
secure: Number(SMTP_PORT) === 465,
|
||||||
|
tls: {
|
||||||
|
// Keep certificate verification enabled by default. A private SMTP
|
||||||
|
// proxy may expose a self-signed certificate; that deployment can
|
||||||
|
// explicitly opt out without weakening unrelated HTTPS requests.
|
||||||
|
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== "false",
|
||||||
|
},
|
||||||
connectionTimeout: 10000,
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
socketTimeout: 20000,
|
socketTimeout: 20000,
|
||||||
@@ -24,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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
SMTP_TLS_REJECT_UNAUTHORIZED=true
|
||||||
INBOUND_REPLY_TO=reply-to@anouma.org
|
INBOUND_REPLY_TO=reply-to@anouma.org
|
||||||
INBOUND_EMAIL_SECRET=
|
INBOUND_EMAIL_SECRET=
|
||||||
LIVEKIT_URL=
|
LIVEKIT_URL=
|
||||||
@@ -352,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
|
||||||
|
|||||||
Reference in New Issue
Block a user