Add mail outbox with SMTP delivery worker and templates
MailService renders transactional mails (verify-email, reset-password) from the new de/en `mails` i18n namespace — text plus minimal HTML with escaped interpolation — and enqueues them into mail_outbox. MailWorker delivers pending rows every 15s through an injectable transport (nodemailer; faked in tests) with quadratic backoff and a permanent FAILED state after five attempts, logged as a warning. SMTP_* and APP_BASE_URL join the environment schema with defaults matching the new Mailpit container in the dev overlay. Closes #12 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
31f23c12a2
commit
f00fb19f32
@ -22,6 +22,7 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"nestjs-pino": "^4.3.0",
|
"nestjs-pino": "^4.3.0",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"pino-http": "^10.4.0",
|
"pino-http": "^10.4.0",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
@ -33,6 +34,7 @@
|
|||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@swc/core": "^1.10.0",
|
"@swc/core": "^1.10.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/supertest": "^6.0.0",
|
"@types/supertest": "^6.0.0",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { ApiExceptionFilter } from './common/api-exception.filter';
|
|||||||
import { AppConfig } from './config/app-config.service';
|
import { AppConfig } from './config/app-config.service';
|
||||||
import { ConfigModule } from './config/config.module';
|
import { ConfigModule } from './config/config.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { MailModule } from './mail/mail.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
import { RateLimitModule } from './rate-limit/rate-limit.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
@ -15,6 +16,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
ConfigModule,
|
ConfigModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RateLimitModule,
|
RateLimitModule,
|
||||||
|
MailModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
LoggerModule.forRootAsync({
|
LoggerModule.forRootAsync({
|
||||||
inject: [AppConfig],
|
inject: [AppConfig],
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
|
||||||
|
import deMails from '@dorfteich/shared/i18n/de/mails.json';
|
||||||
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
|
||||||
|
import enMails from '@dorfteich/shared/i18n/en/mails.json';
|
||||||
import { createInstance, type i18n as I18n } from 'i18next';
|
import { createInstance, type i18n as I18n } from 'i18next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -11,8 +13,8 @@ export const apiI18n: I18n = createInstance();
|
|||||||
|
|
||||||
void apiI18n.init({
|
void apiI18n.init({
|
||||||
resources: {
|
resources: {
|
||||||
en: { errors: enErrors },
|
en: { errors: enErrors, mails: enMails },
|
||||||
de: { errors: deErrors },
|
de: { errors: deErrors, mails: deMails },
|
||||||
},
|
},
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
supportedLngs: ['de', 'en'],
|
supportedLngs: ['de', 'en'],
|
||||||
|
|||||||
53
apps/api/src/mail/mail-templates.ts
Normal file
53
apps/api/src/mail/mail-templates.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { apiI18n } from '../i18n/api-i18n';
|
||||||
|
|
||||||
|
export type MailTemplate = 'verifyEmail' | 'resetPassword';
|
||||||
|
|
||||||
|
export interface RenderedMail {
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the layout-light transactional mails (text + simple HTML) from
|
||||||
|
* the `mails` i18n namespace. Every template gets: greeting, body text,
|
||||||
|
* one action link, expiry note, ignore hint, signoff.
|
||||||
|
*/
|
||||||
|
export function renderMail(
|
||||||
|
template: MailTemplate,
|
||||||
|
params: { displayName: string; link: string },
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
): RenderedMail {
|
||||||
|
const t = (key: string, options: Record<string, string> = {}): string =>
|
||||||
|
apiI18n.t(`mails:${key}`, { lng: locale, ...options });
|
||||||
|
|
||||||
|
const greeting = t('common.greeting', { displayName: params.displayName });
|
||||||
|
const body = t(`${template}.body`);
|
||||||
|
const action = t(`${template}.action`);
|
||||||
|
const expiry = t(`${template}.expiry`);
|
||||||
|
const ignore = t('common.ignoreHint');
|
||||||
|
const signoff = t('common.signoff');
|
||||||
|
|
||||||
|
const text = [greeting, '', body, '', params.link, '', expiry, ignore, '', signoff].join('\n');
|
||||||
|
|
||||||
|
const html = `<!doctype html>
|
||||||
|
<html><body style="font-family: system-ui, sans-serif; color: #1f2933; max-width: 32rem; margin: 0 auto; padding: 1.5rem;">
|
||||||
|
<p>${escapeHtml(greeting)}</p>
|
||||||
|
<p>${escapeHtml(body)}</p>
|
||||||
|
<p style="margin: 1.5rem 0;">
|
||||||
|
<a href="${params.link}" style="background: #2f6f4f; color: #ffffff; padding: 0.6rem 1.2rem; border-radius: 6px; text-decoration: none;">${escapeHtml(action)}</a>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.85rem; color: #616e7c;">${escapeHtml(expiry)}<br>${escapeHtml(ignore)}</p>
|
||||||
|
<p>${escapeHtml(signoff)}</p>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
return { subject: t(`${template}.subject`), text, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
104
apps/api/src/mail/mail-worker.service.ts
Normal file
104
apps/api/src/mail/mail-worker.service.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Inject, Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { MailOutbox } from '@prisma/client';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/** Minimal transport contract — nodemailer in production, a fake in tests. */
|
||||||
|
export interface MailTransport {
|
||||||
|
sendMail(mail: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
}): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAIL_TRANSPORT = 'MAIL_TRANSPORT';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15_000;
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const BATCH_SIZE = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivers pending outbox mail with exponential backoff. Single-instance
|
||||||
|
* by design (one api container per stage, ADR 0002); the M2 jobs table
|
||||||
|
* will absorb this loop once it exists.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MailWorker implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private timer: NodeJS.Timeout | undefined;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: AppConfig,
|
||||||
|
@Inject(MAIL_TRANSPORT) private readonly transport: MailTransport,
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(MailWorker.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (this.config.env.NODE_ENV === 'test') return;
|
||||||
|
this.timer = setInterval(() => void this.deliverDueMail(), POLL_INTERVAL_MS);
|
||||||
|
this.timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One delivery pass; public so tests and later admin tooling can drive it. */
|
||||||
|
async deliverDueMail(): Promise<void> {
|
||||||
|
if (this.running) return; // never overlap two passes
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const due = await this.prisma.mailOutbox.findMany({
|
||||||
|
where: { status: 'PENDING', nextAttemptAt: { lte: new Date() } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: BATCH_SIZE,
|
||||||
|
});
|
||||||
|
for (const mail of due) {
|
||||||
|
await this.deliverOne(mail);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverOne(mail: MailOutbox): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.transport.sendMail({
|
||||||
|
from: this.config.env.SMTP_FROM,
|
||||||
|
to: mail.toAddress,
|
||||||
|
subject: mail.subject,
|
||||||
|
text: mail.textBody,
|
||||||
|
html: mail.htmlBody,
|
||||||
|
});
|
||||||
|
await this.prisma.mailOutbox.update({
|
||||||
|
where: { id: mail.id },
|
||||||
|
data: { status: 'SENT', sentAt: new Date(), attempts: mail.attempts + 1 },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const attempts = mail.attempts + 1;
|
||||||
|
const failedForGood = attempts >= MAX_ATTEMPTS;
|
||||||
|
// Backoff: 30s, 2min, 4.5min, 8min — then give up loudly.
|
||||||
|
const delayMs = 30_000 * attempts * attempts;
|
||||||
|
await this.prisma.mailOutbox.update({
|
||||||
|
where: { id: mail.id },
|
||||||
|
data: {
|
||||||
|
attempts,
|
||||||
|
status: failedForGood ? 'FAILED' : 'PENDING',
|
||||||
|
nextAttemptAt: new Date(Date.now() + delayMs),
|
||||||
|
lastError: error instanceof Error ? error.message.slice(0, 500) : String(error),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (failedForGood) {
|
||||||
|
this.logger.warn({ mailId: mail.id, attempts }, 'mail delivery failed permanently');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apps/api/src/mail/mail.db.test.ts
Normal file
105
apps/api/src/mail/mail.db.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
|
import { MailTransport, MailWorker } from './mail-worker.service';
|
||||||
|
import { renderMail } from './mail-templates';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
|
||||||
|
describe('mail templates', () => {
|
||||||
|
it('renders both languages with link, action, and greeting', () => {
|
||||||
|
for (const locale of ['de', 'en'] as const) {
|
||||||
|
const mail = renderMail(
|
||||||
|
'verifyEmail',
|
||||||
|
{ displayName: 'Uma', link: 'https://example.org/verify?token=abc' },
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
expect(mail.subject.length).toBeGreaterThan(5);
|
||||||
|
expect(mail.text).toContain('https://example.org/verify?token=abc');
|
||||||
|
expect(mail.text).toContain('Uma');
|
||||||
|
expect(mail.html).toContain('href="https://example.org/verify?token=abc"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in interpolated values', () => {
|
||||||
|
const mail = renderMail(
|
||||||
|
'resetPassword',
|
||||||
|
{ displayName: '<script>x</script>', link: 'https://example.org/r' },
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
expect(mail.html).not.toContain('<script>x</script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasTestDb)('MailWorker (database)', () => {
|
||||||
|
const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!;
|
||||||
|
const suffix = uniqueSuffix();
|
||||||
|
const address = `worker-${suffix}@example.org`;
|
||||||
|
let config: AppConfig;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.DATABASE_URL ??= process.env.TEST_DATABASE_URL;
|
||||||
|
config = new AppConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!hasTestDb) return;
|
||||||
|
await prisma.mailOutbox.deleteMany({ where: { toAddress: { contains: suffix } } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWorker(transport: MailTransport): MailWorker {
|
||||||
|
const logger = { setContext: vi.fn(), warn: vi.fn() } as unknown as PinoLogger;
|
||||||
|
return new MailWorker(prisma, config, transport, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('delivers queued mail and marks it sent', async () => {
|
||||||
|
await new MailService(prisma).enqueue(
|
||||||
|
address,
|
||||||
|
'verifyEmail',
|
||||||
|
{ displayName: 'Uma', link: 'https://example.org/v' },
|
||||||
|
'de',
|
||||||
|
);
|
||||||
|
const sent: string[] = [];
|
||||||
|
await makeWorker({
|
||||||
|
sendMail: async (mail) => {
|
||||||
|
sent.push(mail.to);
|
||||||
|
},
|
||||||
|
}).deliverDueMail();
|
||||||
|
|
||||||
|
expect(sent).toContain(address);
|
||||||
|
const row = await prisma.mailOutbox.findFirst({ where: { toAddress: address } });
|
||||||
|
expect(row?.status).toBe('SENT');
|
||||||
|
expect(row?.sentAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries with backoff and fails permanently after five attempts', async () => {
|
||||||
|
const failing = `fail-${suffix}@example.org`;
|
||||||
|
await new MailService(prisma).enqueue(
|
||||||
|
failing,
|
||||||
|
'resetPassword',
|
||||||
|
{ displayName: 'Uma', link: 'https://example.org/r' },
|
||||||
|
'en',
|
||||||
|
);
|
||||||
|
const worker = makeWorker({
|
||||||
|
sendMail: async () => {
|
||||||
|
throw new Error('smtp down');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||||
|
// Make the row due again regardless of the stored backoff.
|
||||||
|
await prisma.mailOutbox.updateMany({
|
||||||
|
where: { toAddress: failing },
|
||||||
|
data: { nextAttemptAt: new Date() },
|
||||||
|
});
|
||||||
|
await worker.deliverDueMail();
|
||||||
|
const row = await prisma.mailOutbox.findFirst({ where: { toAddress: failing } });
|
||||||
|
expect(row?.attempts).toBe(attempt);
|
||||||
|
expect(row?.status).toBe(attempt < 5 ? 'PENDING' : 'FAILED');
|
||||||
|
expect(row?.lastError).toContain('smtp down');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
28
apps/api/src/mail/mail.module.ts
Normal file
28
apps/api/src/mail/mail.module.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
|
||||||
|
import { AppConfig } from '../config/app-config.service';
|
||||||
|
import { MAIL_TRANSPORT, MailWorker } from './mail-worker.service';
|
||||||
|
import { MailService } from './mail.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
MailService,
|
||||||
|
MailWorker,
|
||||||
|
{
|
||||||
|
provide: MAIL_TRANSPORT,
|
||||||
|
inject: [AppConfig],
|
||||||
|
useFactory: (config: AppConfig) =>
|
||||||
|
createTransport({
|
||||||
|
host: config.env.SMTP_HOST,
|
||||||
|
port: config.env.SMTP_PORT,
|
||||||
|
secure: config.env.SMTP_SECURE,
|
||||||
|
auth: config.env.SMTP_USER
|
||||||
|
? { user: config.env.SMTP_USER, pass: config.env.SMTP_PASS }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
30
apps/api/src/mail/mail.service.ts
Normal file
30
apps/api/src/mail/mail.service.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { MailTemplate, renderMail } from './mail-templates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues transactional mail into the outbox. Actual delivery happens
|
||||||
|
* asynchronously in MailWorker — callers never wait on SMTP.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async enqueue(
|
||||||
|
to: string,
|
||||||
|
template: MailTemplate,
|
||||||
|
params: { displayName: string; link: string },
|
||||||
|
locale: 'de' | 'en',
|
||||||
|
): Promise<void> {
|
||||||
|
const rendered = renderMail(template, params, locale);
|
||||||
|
await this.prisma.mailOutbox.create({
|
||||||
|
data: {
|
||||||
|
toAddress: to,
|
||||||
|
subject: rendered.subject,
|
||||||
|
textBody: rendered.text,
|
||||||
|
htmlBody: rendered.html,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,14 @@ services:
|
|||||||
# instances (5432 system, 5433 wochenplan-staging).
|
# instances (5432 system, 5433 wochenplan-staging).
|
||||||
- '127.0.0.1:5434:5432'
|
- '127.0.0.1:5434:5432'
|
||||||
|
|
||||||
|
# Local SMTP catcher: UI on http://localhost:8025, SMTP on 1025 —
|
||||||
|
# matches the api's SMTP_* defaults.
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:1025:1025'
|
||||||
|
- '127.0.0.1:8025:8025'
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
web-root-modules:
|
web-root-modules:
|
||||||
web-app-modules:
|
web-app-modules:
|
||||||
|
|||||||
19
packages/shared/i18n/de/mails.json
Normal file
19
packages/shared/i18n/de/mails.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"greeting": "Hallo {{displayName}},",
|
||||||
|
"signoff": "Dein Dorfteich",
|
||||||
|
"ignoreHint": "Falls du diese E-Mail nicht angefordert hast, kannst du sie einfach ignorieren."
|
||||||
|
},
|
||||||
|
"verifyEmail": {
|
||||||
|
"subject": "Bestätige deine E-Mail-Adresse",
|
||||||
|
"body": "willkommen im Dorfteich! Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren:",
|
||||||
|
"action": "E-Mail-Adresse bestätigen",
|
||||||
|
"expiry": "Der Link ist 24 Stunden gültig."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"subject": "Passwort zurücksetzen",
|
||||||
|
"body": "jemand (hoffentlich du) hat das Zurücksetzen des Passworts für dein Konto angefordert. Über diesen Link kannst du ein neues Passwort setzen:",
|
||||||
|
"action": "Neues Passwort setzen",
|
||||||
|
"expiry": "Der Link ist eine Stunde gültig. Dein aktuelles Passwort bleibt gültig, bis du ein neues gesetzt hast."
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/shared/i18n/en/mails.json
Normal file
19
packages/shared/i18n/en/mails.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"greeting": "Hello {{displayName}},",
|
||||||
|
"signoff": "Your Dorfteich",
|
||||||
|
"ignoreHint": "If you did not request this e-mail, you can safely ignore it."
|
||||||
|
},
|
||||||
|
"verifyEmail": {
|
||||||
|
"subject": "Confirm your e-mail address",
|
||||||
|
"body": "welcome to Dorfteich! Please confirm your e-mail address to activate your account:",
|
||||||
|
"action": "Confirm e-mail address",
|
||||||
|
"expiry": "The link is valid for 24 hours."
|
||||||
|
},
|
||||||
|
"resetPassword": {
|
||||||
|
"subject": "Reset your password",
|
||||||
|
"body": "someone (hopefully you) requested a password reset for your account. Use this link to set a new password:",
|
||||||
|
"action": "Set new password",
|
||||||
|
"expiry": "The link is valid for one hour. Your current password stays valid until you set a new one."
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,6 +24,22 @@ export const apiEnvSchema = z.object({
|
|||||||
.enum(['true', 'false'])
|
.enum(['true', 'false'])
|
||||||
.default('true')
|
.default('true')
|
||||||
.transform((value) => value === 'true'),
|
.transform((value) => value === 'true'),
|
||||||
|
/** Public base URL of this instance — used in e-mail links. */
|
||||||
|
APP_BASE_URL: z.string().url().default('http://localhost:5173'),
|
||||||
|
/**
|
||||||
|
* SMTP delivery. Defaults match the Mailpit container from the dev
|
||||||
|
* overlay; production instances configure their real relay here (the
|
||||||
|
* M8 setup wizard writes these).
|
||||||
|
*/
|
||||||
|
SMTP_HOST: z.string().default('localhost'),
|
||||||
|
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||||
|
SMTP_SECURE: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.transform((value) => value === 'true'),
|
||||||
|
SMTP_USER: z.string().optional(),
|
||||||
|
SMTP_PASS: z.string().optional(),
|
||||||
|
SMTP_FROM: z.string().default('Dorfteich <no-reply@localhost>'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@ -53,6 +53,9 @@ importers:
|
|||||||
nestjs-pino:
|
nestjs-pino:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2)
|
version: 4.6.1(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.2)
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^9.0.3
|
||||||
|
version: 9.0.3
|
||||||
pino:
|
pino:
|
||||||
specifier: ^9.6.0
|
specifier: ^9.6.0
|
||||||
version: 9.14.0
|
version: 9.14.0
|
||||||
@ -81,6 +84,9 @@ importers:
|
|||||||
'@types/express':
|
'@types/express':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
@ -1426,6 +1432,9 @@ packages:
|
|||||||
'@types/node@26.1.0':
|
'@types/node@26.1.0':
|
||||||
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
'@types/qs@6.15.1':
|
'@types/qs@6.15.1':
|
||||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||||
|
|
||||||
@ -2630,6 +2639,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@9.0.3:
|
||||||
|
resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
nypm@0.6.8:
|
nypm@0.6.8:
|
||||||
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
|
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -4545,6 +4558,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.3.0
|
undici-types: 8.3.0
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.1.0
|
||||||
|
|
||||||
'@types/qs@6.15.1': {}
|
'@types/qs@6.15.1': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
@ -5840,6 +5857,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.50: {}
|
node-releases@2.0.50: {}
|
||||||
|
|
||||||
|
nodemailer@9.0.3: {}
|
||||||
|
|
||||||
nypm@0.6.8:
|
nypm@0.6.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
citty: 0.2.2
|
citty: 0.2.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user