feat(backend): mail module + send personal Gemeinde-Teamer invites
New global mail/ module mirroring the files/storage/ provider pattern: - MailProvider abstraction; default LogMailProvider only logs (no delivery), MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*, MAIL_FROM). - MailService.sendTeamerInvite() composes the invite email with a link built from APP_BASE_URL. TeamerService.createInvite() now mails personal invites (those with an email) best-effort and returns `emailSent`; group links are unchanged. Delivery failures are logged and swallowed, never blocking invite creation. New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/ USER/PASS. Tests: teamer spec covers mail-on-personal-invite, no-mail-on-group-link, and transport-drop; npm test green at 56. Docs updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||
@@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module';
|
||||
exclude: ['/api*'],
|
||||
}),
|
||||
PrismaModule,
|
||||
MailModule,
|
||||
SyncModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MailMessage, MailProvider } from './mail-provider';
|
||||
|
||||
/// Default provider: doesn't send anything, just logs that it would have.
|
||||
/// Keeps the invite flow working before SMTP is configured.
|
||||
export class LogMailProvider implements MailProvider {
|
||||
private readonly logger = new Logger('MailProvider');
|
||||
|
||||
async send(message: MailMessage): Promise<boolean> {
|
||||
this.logger.log(
|
||||
`[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Abstraction over the outbound email backend. Default is a no-send provider
|
||||
/// that only logs (fine for dev and for deployments that don't do email yet);
|
||||
/// MAIL_PROVIDER=smtp switches to a real SMTP transport.
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export interface MailProvider {
|
||||
/// Resolves true if the message was handed off to the transport, false if
|
||||
/// it was dropped (e.g. the log provider). Never throws for delivery
|
||||
/// problems — callers treat email as best-effort.
|
||||
send(message: MailMessage): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MAIL_PROVIDER } from './mail-provider';
|
||||
import { LogMailProvider } from './log-mail.provider';
|
||||
import { SmtpMailProvider } from './smtp-mail.provider';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/// Global so any feature module can inject MailService. Provider defaults to
|
||||
/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
MailService,
|
||||
{
|
||||
provide: MAIL_PROVIDER,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
config.get<string>('MAIL_PROVIDER') === 'smtp'
|
||||
? new SmtpMailProvider(config)
|
||||
: new LogMailProvider(),
|
||||
},
|
||||
],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MAIL_PROVIDER, MailProvider } from './mail-provider';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly appBaseUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.appBaseUrl = (config.get<string>('APP_BASE_URL') ?? 'http://localhost:3000').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends a personal Gemeinde-Teamer invite. Returns whether it was handed
|
||||
/// to the transport (false for the log-only provider or on failure).
|
||||
sendTeamerInvite(opts: {
|
||||
to: string;
|
||||
kcName: string;
|
||||
gemeindeName: string;
|
||||
token: string;
|
||||
expiresAt: Date | null;
|
||||
}): Promise<boolean> {
|
||||
const link = `${this.appBaseUrl}/?teamerInviteToken=${encodeURIComponent(opts.token)}`;
|
||||
const expiry = opts.expiresAt
|
||||
? `\n\nDer Link gilt bis ${opts.expiresAt.toISOString()}.`
|
||||
: '';
|
||||
return this.provider.send({
|
||||
to: opts.to,
|
||||
subject: `Einladung als Teamer:in – ${opts.gemeindeName} (${opts.kcName})`,
|
||||
text:
|
||||
`Hallo,\n\ndu wurdest als Teamer:in für die Gemeinde "${opts.gemeindeName}" ` +
|
||||
`beim ${opts.kcName} eingeladen.\n\n` +
|
||||
`Konto anlegen: ${link}\n\n` +
|
||||
`Falls der Link nicht funktioniert, nutze diesen Einladungscode: ${opts.token}` +
|
||||
`${expiry}\n`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailMessage, MailProvider } from './mail-provider';
|
||||
|
||||
/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and
|
||||
/// swallowed — callers treat email as best-effort.
|
||||
export class SmtpMailProvider implements MailProvider {
|
||||
private readonly logger = new Logger('MailProvider');
|
||||
private readonly from: string;
|
||||
private readonly transport: nodemailer.Transporter;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.from = config.getOrThrow<string>('MAIL_FROM');
|
||||
this.transport = nodemailer.createTransport({
|
||||
host: config.getOrThrow<string>('SMTP_HOST'),
|
||||
port: Number(config.get<string>('SMTP_PORT') ?? 587),
|
||||
secure: config.get<string>('SMTP_SECURE') === 'true',
|
||||
auth: config.get<string>('SMTP_USER')
|
||||
? {
|
||||
user: config.getOrThrow<string>('SMTP_USER'),
|
||||
pass: config.getOrThrow<string>('SMTP_PASS'),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async send(message: MailMessage): Promise<boolean> {
|
||||
try {
|
||||
await this.transport.sendMail({
|
||||
from: this.from,
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to send "${message.subject}" to ${message.to}: ${(err as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
|
||||
|
||||
const prisma = {
|
||||
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
|
||||
kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) },
|
||||
user: {
|
||||
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
|
||||
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
|
||||
@@ -56,8 +57,9 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?:
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new TeamerService(prisma as never, sync as never);
|
||||
return { service, prisma, sync, created };
|
||||
const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) };
|
||||
const service = new TeamerService(prisma as never, sync as never, mail as never);
|
||||
return { service, prisma, sync, mail, created };
|
||||
}
|
||||
|
||||
describe('TeamerService scope check', () => {
|
||||
@@ -120,20 +122,34 @@ describe('TeamerService.createTeamer', () => {
|
||||
});
|
||||
|
||||
describe('TeamerService.createInvite', () => {
|
||||
it('defaults a group link to unlimited uses and no expiry', async () => {
|
||||
const { service } = makeService();
|
||||
it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', {});
|
||||
expect(inv.email).toBeNull();
|
||||
expect(inv.maxUses).toBeNull();
|
||||
expect(inv.expiresAt).toBeNull();
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(mail.sendTeamerInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults a personal invite to a single use and lowercases the email', async () => {
|
||||
const { service } = makeService();
|
||||
it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
|
||||
expect(inv.email).toBe('new@example.org');
|
||||
expect(inv.maxUses).toBe(1);
|
||||
expect(inv.emailSent).toBe(true);
|
||||
expect(mail.sendTeamerInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still returns the invite when the mail transport drops it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
mail.sendTeamerInvite.mockResolvedValueOnce(false);
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' });
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('turns expiresInHours into a concrete expiry', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Role, SyncOperation } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
|
||||
|
||||
@@ -30,6 +31,7 @@ export class TeamerService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly mail: MailService,
|
||||
) {}
|
||||
|
||||
async createTeamer(
|
||||
@@ -118,7 +120,24 @@ export class TeamerService {
|
||||
},
|
||||
});
|
||||
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
|
||||
return invite;
|
||||
|
||||
// Personal invites go out by email (best-effort); group links are shared
|
||||
// by the Verantwortliche/r directly.
|
||||
let emailSent = false;
|
||||
if (email) {
|
||||
const kc = await this.prisma.kc.findUnique({
|
||||
where: { id: gemeinde.kcId },
|
||||
select: { name: true },
|
||||
});
|
||||
emailSent = await this.mail.sendTeamerInvite({
|
||||
to: email,
|
||||
kcName: kc?.name ?? '',
|
||||
gemeindeName: gemeinde.name,
|
||||
token: invite.token,
|
||||
expiresAt: invite.expiresAt,
|
||||
});
|
||||
}
|
||||
return { ...invite, emailSent };
|
||||
}
|
||||
|
||||
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
|
||||
|
||||
Reference in New Issue
Block a user