mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 03:41:10 +02:00
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:
parent
85e8ff047a
commit
fafa550a60
5 changed files with 1416 additions and 617 deletions
|
|
@ -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
1755
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
236
services/mana-core-auth/src/email/email.service.ts
Normal file
236
services/mana-core-auth/src/email/email.service.ts
Normal 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>
|
||||
`,
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue