feat(auth): add Brevo SMTP email service for transactional emails

- Add nodemailer-based email service with Brevo SMTP integration
- Implement password reset, invitation, and welcome email templates
- Update better-auth.config.ts to use email service for sendResetPassword and sendInvitationEmail
- Add SMTP environment variables to docker-compose.macmini.yml
- Change minimum password length from 12 to 8 characters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-26 14:07:31 +01:00
parent 85e8ff047a
commit fafa550a60
5 changed files with 1416 additions and 617 deletions

View file

@ -82,6 +82,13 @@ services:
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-${JWT_SECRET:-your-jwt-secret-change-me}}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY:-}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:-}
BASE_URL: https://auth.mana.how
# SMTP for transactional emails (Brevo)
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ManaCore <noreply@mana.how>
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how
ports:
- "3001:3001"

1755
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,7 @@
"jose": "^6.1.2",
"jsonwebtoken": "^9.0.2",
"nanoid": "^5.0.9",
"nodemailer": "^7.0.12",
"postgres": "^3.4.5",
"prom-client": "^15.1.0",
"redis": "^4.7.0",
@ -60,6 +61,7 @@
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@types/nodemailer": "^7.0.5",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",

View file

@ -22,6 +22,7 @@ import { getDb } from '../db/connection';
import { organizations, members, invitations } from '../db/schema/organizations.schema';
import { users, sessions, accounts, verificationTokens, jwks } from '../db/schema/auth.schema';
import type { JWTPayloadContext } from './types/better-auth.types';
import { sendPasswordResetEmail, sendInvitationEmail } from '../email/email.service';
/**
* JWT Custom Payload Interface
@ -96,19 +97,8 @@ export function createBetterAuth(databaseUrl: string) {
*
* @see https://www.better-auth.com/docs/authentication/email-password#password-reset
*/
sendResetPassword: async ({ user, url, token }) => {
// TODO: Implement email sending service (e.g., Resend, SendGrid)
// For now, log the reset URL for development
console.log('[Password Reset] User:', user.email);
console.log('[Password Reset] Reset URL:', url);
console.log('[Password Reset] Token:', token);
// In production, send an email like:
// await sendEmail({
// to: user.email,
// subject: 'Reset your password',
// html: `<a href="${url}">Reset your password</a>`
// });
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url, user.name);
},
},
@ -143,14 +133,15 @@ export function createBetterAuth(databaseUrl: string) {
// Email invitation handler
async sendInvitationEmail(data) {
const { email, organization } = data;
// TODO: Implement email sending service
console.log('TODO: Send invitation email', {
to: email,
organization: organization.name,
invitationId: data.id,
});
const { email, organization, inviter } = data;
const baseUrl = process.env.BASE_URL || 'https://mana.how';
const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
await sendInvitationEmail(
email,
organization.name,
inviter?.name || 'Ein Teammitglied',
inviteUrl
);
},
// Custom roles and permissions

View file

@ -0,0 +1,236 @@
/**
* Email Service
*
* Sends transactional emails via Brevo SMTP for:
* - Password reset
* - Email verification
* - Organization invitations
*/
import * as nodemailer from 'nodemailer';
interface EmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
// Create reusable transporter
let transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (transporter) {
return transporter;
}
const host = process.env.SMTP_HOST || 'smtp-relay.brevo.com';
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASSWORD;
if (!user || !pass) {
console.warn('[Email] SMTP credentials not configured, emails will be logged only');
return null as any;
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465, // true for 465, false for other ports
auth: {
user,
pass,
},
});
return transporter;
}
/**
* Send an email via Brevo SMTP
*/
export async function sendEmail(options: EmailOptions): Promise<boolean> {
const { to, subject, html, text } = options;
const from = process.env.SMTP_FROM || 'ManaCore <noreply@mana.how>';
console.log(`[Email] Sending to: ${to}, subject: ${subject}`);
const transport = getTransporter();
if (!transport) {
console.log('[Email] No SMTP configured, logging email content:');
console.log(` To: ${to}`);
console.log(` Subject: ${subject}`);
console.log(` HTML: ${html.substring(0, 200)}...`);
return false;
}
try {
const result = await transport.sendMail({
from,
to,
subject,
html,
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
});
console.log(`[Email] Sent successfully, messageId: ${result.messageId}`);
return true;
} catch (error) {
console.error('[Email] Failed to send:', error);
return false;
}
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
resetUrl: string,
userName?: string
): Promise<boolean> {
const name = userName || email.split('@')[0];
return sendEmail({
to: email,
subject: 'Passwort zurücksetzen - ManaCore',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo ${name},</p>
<p>Du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt. Klicke auf den Button unten, um ein neues Passwort zu erstellen:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${resetUrl}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Passwort zurücksetzen</a>
</div>
<p style="color: #666; font-size: 14px;">Dieser Link ist 1 Stunde gültig. Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.<br>
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:<br>
<a href="${resetUrl}" style="color: #2563eb; word-break: break-all;">${resetUrl}</a>
</p>
</body>
</html>
`,
});
}
/**
* Send organization invitation email
*/
export async function sendInvitationEmail(
email: string,
organizationName: string,
inviterName: string,
inviteUrl: string
): Promise<boolean> {
return sendEmail({
to: email,
subject: `Einladung zu ${organizationName} - ManaCore`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo,</p>
<p><strong>${inviterName}</strong> hat dich eingeladen, der Organisation <strong>${organizationName}</strong> auf ManaCore beizutreten.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${inviteUrl}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Einladung annehmen</a>
</div>
<p style="color: #666; font-size: 14px;">Diese Einladung ist 7 Tage gültig.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.
</p>
</body>
</html>
`,
});
}
/**
* Send welcome/verification email
*/
export async function sendWelcomeEmail(
email: string,
userName?: string,
verificationUrl?: string
): Promise<boolean> {
const name = userName || email.split('@')[0];
const hasVerification = !!verificationUrl;
return sendEmail({
to: email,
subject: 'Willkommen bei ManaCore!',
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="color: #2563eb; margin: 0;">ManaCore</h1>
</div>
<p>Hallo ${name},</p>
<p>Willkommen bei ManaCore! Dein Account wurde erfolgreich erstellt.</p>
${
hasVerification
? `
<p>Bitte bestätige deine E-Mail-Adresse, indem du auf den Button unten klickst:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${verificationUrl}" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">E-Mail bestätigen</a>
</div>
`
: `
<p>Du kannst dich jetzt mit deiner E-Mail-Adresse und deinem Passwort anmelden.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="https://mana.how" style="background-color: #2563eb; color: white; padding: 12px 30px; text-decoration: none; border-radius: 6px; font-weight: 500; display: inline-block;">Zu ManaCore</a>
</div>
`
}
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px; text-align: center;">
Diese E-Mail wurde automatisch von ManaCore gesendet.
</p>
</body>
</html>
`,
});
}