feat(auth): add Brevo email integration for password reset and org invites

This commit is contained in:
Wuesteon 2025-12-16 03:33:15 +01:00
parent f37f85eded
commit c5ffd92bad
14 changed files with 1041 additions and 554 deletions

View file

@ -35,3 +35,10 @@ CREDITS_DAILY_FREE=5
# Rate Limiting
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100
# Email (Brevo)
# Get your API key from: https://app.brevo.com/settings/keys/api
# Without this key, emails are logged to console only (dev mode)
BREVO_API_KEY=
EMAIL_SENDER_ADDRESS=noreply@manacore.app
EMAIL_SENDER_NAME=ManaCore

View file

@ -57,6 +57,7 @@ jwt.verify(token, publicKey, { algorithms: ['RS256'] });
- **Auth**: Better Auth with JWT + Organization plugins
- **Database**: PostgreSQL with Drizzle ORM
- **JWT Library**: `jose` (NOT `jsonwebtoken`)
- **Email**: Brevo (transactional emails)
## Commands
@ -89,6 +90,10 @@ services/mana-core-auth/
│ │ ├── auth.controller.ts # Auth endpoints
│ │ └── dto/ # Request DTOs
│ ├── credits/ # Credit system
│ ├── email/
│ │ ├── email.module.ts # NestJS email module
│ │ ├── email.service.ts # Email service (for NestJS DI)
│ │ └── brevo-client.ts # Standalone Brevo client
│ ├── db/
│ │ ├── schema/ # Drizzle schemas
│ │ ├── migrations/ # Generated migration files
@ -118,6 +123,8 @@ Key points:
| `src/auth/better-auth.config.ts` | Better Auth configuration with JWT + Org plugins |
| `src/auth/services/better-auth.service.ts` | Main auth service - ALL auth logic here |
| `src/auth/types/better-auth.types.ts` | Type definitions (inferred + manual) |
| `src/email/email.service.ts` | NestJS email service (use in controllers) |
| `src/email/brevo-client.ts` | Standalone Brevo client (used by Better Auth) |
| `src/db/schema/auth.schema.ts` | User, session, account, jwks tables |
| `docs/AUTHENTICATION_ARCHITECTURE.md` | Comprehensive auth documentation |
| `docs/BETTER_AUTH_TYPING_IMPROVEMENTS.md` | TypeScript typing decisions and limitations |
@ -133,6 +140,12 @@ JWT_AUDIENCE=manacore
# NOT required for Better Auth JWT (auto-generates EdDSA keys)
# JWT_PRIVATE_KEY=... # DON'T USE - Better Auth uses jwks table
# JWT_PUBLIC_KEY=... # DON'T USE - Better Auth uses jwks table
# Email (Brevo) - optional for development
# Without BREVO_API_KEY, emails are logged to console
BREVO_API_KEY=your-api-key-here
EMAIL_SENDER_ADDRESS=noreply@manacore.app
EMAIL_SENDER_NAME=ManaCore
```
## Common Tasks
@ -189,6 +202,60 @@ user: {
},
```
## Email Configuration
### Overview
Transactional emails are sent via **Brevo** (formerly Sendinblue). The email system supports:
- Password reset emails
- Organization invitation emails
- Email verification (future)
### Architecture
There are two email implementations:
1. **`brevo-client.ts`** - Standalone client for Better Auth config (no NestJS DI)
2. **`email.service.ts`** - NestJS service for use in controllers
Better Auth hooks (`sendResetPassword`, `sendInvitationEmail`) use the standalone client because they run before NestJS DI is available.
### Development Mode
Without `BREVO_API_KEY`, emails are **logged to console** instead of being sent. This is useful for development and testing.
### Production Setup
1. Get your API key from: https://app.brevo.com/settings/keys/api
2. Set environment variables:
```env
BREVO_API_KEY=xkeysib-...
EMAIL_SENDER_ADDRESS=noreply@manacore.app
EMAIL_SENDER_NAME=ManaCore
```
3. Verify your sender domain in Brevo dashboard for better deliverability
### Using EmailService in Controllers
```typescript
import { EmailService } from '../email';
@Controller('api')
export class MyController {
constructor(private emailService: EmailService) {}
@Post('notify')
async sendNotification() {
await this.emailService.sendEmail({
to: 'user@example.com',
subject: 'Notification',
htmlContent: '<p>Hello!</p>',
});
}
}
```
## Debugging
### Token not validating?

View file

@ -21,6 +21,7 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@getbrevo/brevo": "^3.0.1",
"@google/generative-ai": "^0.24.1",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",

View file

@ -5,6 +5,7 @@ import { APP_FILTER } from '@nestjs/core';
import configuration from './config/configuration';
import { AuthModule } from './auth/auth.module';
import { CreditsModule } from './credits/credits.module';
import { EmailModule } from './email/email.module';
import { FeedbackModule } from './feedback/feedback.module';
import { ReferralsModule } from './referrals/referrals.module';
import { SettingsModule } from './settings/settings.module';
@ -28,6 +29,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
AiModule,
AuthModule,
CreditsModule,
EmailModule,
FeedbackModule,
HealthModule,
ReferralsModule,

View file

@ -22,6 +22,7 @@ import { z } from 'zod';
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 { sendPasswordResetEmail, sendOrganizationInviteEmail } from '../email/brevo-client';
/**
* User role schema with Zod runtime validation
@ -121,21 +122,18 @@ export function createBetterAuth(databaseUrl: string) {
* - auth.api.requestPasswordReset({ body: { email } }) - Sends reset email
* - auth.api.resetPassword({ body: { newPassword, token } }) - Resets password
*
* Uses Brevo API to send transactional emails.
* Set BREVO_API_KEY environment variable to enable email sending.
* Without the API key, emails are logged to console (dev mode).
*
* @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({
email: user.email,
name: user.name || undefined,
resetUrl: url,
});
},
},
@ -201,15 +199,24 @@ export function createBetterAuth(databaseUrl: string) {
// Allow users to create their own organizations
allowUserToCreateOrganization: true,
// Email invitation handler
/**
* Email invitation handler
*
* Uses Brevo API to send organization invitation emails.
* Set BREVO_API_KEY environment variable to enable email sending.
* Without the API key, emails are logged to console (dev mode).
*/
async sendInvitationEmail(data) {
const { email, organization } = data;
const { email, organization, role, inviter } = data;
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
const inviteUrl = `${baseUrl}/accept-invitation?id=${data.id}`;
// TODO: Implement email sending service
console.log('TODO: Send invitation email', {
to: email,
organization: organization.name,
invitationId: data.id,
await sendOrganizationInviteEmail({
email,
organizationName: organization.name,
inviterName: inviter?.user?.name || undefined,
inviteUrl,
role: role || 'member',
});
},

View file

@ -49,4 +49,10 @@ export default () => ({
ai: {
geminiApiKey: process.env.GOOGLE_GENAI_API_KEY || '',
},
email: {
brevoApiKey: process.env.BREVO_API_KEY || '',
senderAddress: process.env.EMAIL_SENDER_ADDRESS || 'noreply@manacore.app',
senderName: process.env.EMAIL_SENDER_NAME || 'ManaCore',
},
});

View file

@ -0,0 +1,252 @@
/**
* Standalone Brevo Email Client
*
* This is a standalone email client that can be used outside of NestJS DI,
* specifically for Better Auth email handlers which are initialized before
* the NestJS application context is available.
*
* For regular application code, use the EmailService instead.
*/
import * as brevo from '@getbrevo/brevo';
interface BrevoConfig {
apiKey: string | undefined;
senderEmail: string;
senderName: string;
}
interface SendEmailParams {
to: string;
subject: string;
htmlContent: string;
textContent?: string;
}
/**
* Get Brevo configuration from environment variables
*/
function getConfig(): BrevoConfig {
return {
apiKey: process.env.BREVO_API_KEY,
senderEmail: process.env.EMAIL_SENDER_ADDRESS || 'noreply@manacore.app',
senderName: process.env.EMAIL_SENDER_NAME || 'ManaCore',
};
}
/**
* Send an email using Brevo API
*
* Falls back to console logging if BREVO_API_KEY is not set
*/
export async function sendEmail(params: SendEmailParams): Promise<boolean> {
const config = getConfig();
const { to, subject, htmlContent, textContent } = params;
if (!config.apiKey) {
console.log('[Email - DEV MODE] Would send email:');
console.log(` To: ${to}`);
console.log(` Subject: ${subject}`);
console.log(` Content preview: ${htmlContent.substring(0, 200)}...`);
return true;
}
try {
const apiInstance = new brevo.TransactionalEmailsApi();
apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, config.apiKey);
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.subject = subject;
sendSmtpEmail.htmlContent = htmlContent;
sendSmtpEmail.textContent = textContent;
sendSmtpEmail.sender = {
name: config.senderName,
email: config.senderEmail,
};
sendSmtpEmail.to = [{ email: to }];
const response = await apiInstance.sendTransacEmail(sendSmtpEmail);
console.log(`[Email] Sent to ${to}, messageId: ${response.body.messageId}`);
return true;
} catch (error) {
console.error(`[Email] Failed to send to ${to}:`, error);
return false;
}
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(params: {
email: string;
name?: string;
resetUrl: string;
}): Promise<boolean> {
const { email, name, resetUrl } = params;
const displayName = name || 'there';
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 20px;">
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset Your Password</h1>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
Hi ${displayName},
</p>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
We received a request to reset the password for your ManaCore account. Click the button below to choose a new password:
</p>
<table role="presentation" style="margin: 30px 0;">
<tr>
<td>
<a href="${resetUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
Reset Password
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
If the button above doesn't work, copy and paste this URL into your browser:<br>
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 40px 40px;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
&copy; ${new Date().getFullYear()} ManaCore. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const textContent = `
Reset Your Password
Hi ${displayName},
We received a request to reset the password for your ManaCore account.
Reset your password by visiting this link:
${resetUrl}
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
© ${new Date().getFullYear()} ManaCore. All rights reserved.
`;
return sendEmail({
to: email,
subject: 'Reset Your Password - ManaCore',
htmlContent,
textContent,
});
}
/**
* Send organization invitation email
*/
export async function sendOrganizationInviteEmail(params: {
email: string;
organizationName: string;
inviterName?: string;
inviteUrl: string;
role: string;
}): Promise<boolean> {
const { email, organizationName, inviterName, inviteUrl, role } = params;
const inviterText = inviterName ? `${inviterName} has invited you` : 'You have been invited';
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Organization Invitation</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 20px;">
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You're Invited!</h1>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
${inviterText} to join <strong>${organizationName}</strong> on ManaCore as a <strong>${role}</strong>.
</p>
<table role="presentation" style="margin: 30px 0;">
<tr>
<td>
<a href="${inviteUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
Accept Invitation
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
If the button above doesn't work, copy and paste this URL into your browser:<br>
<a href="${inviteUrl}" style="color: #6366f1; word-break: break-all;">${inviteUrl}</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 40px 40px;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
&copy; ${new Date().getFullYear()} ManaCore. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const textContent = `
You're Invited!
${inviterText} to join ${organizationName} on ManaCore as a ${role}.
Accept your invitation by visiting this link:
${inviteUrl}
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
© ${new Date().getFullYear()} ManaCore. All rights reserved.
`;
return sendEmail({
to: email,
subject: `You're invited to join ${organizationName} - ManaCore`,
htmlContent,
textContent,
});
}

View file

@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
/**
* Email Module
*
* Provides transactional email functionality using Brevo.
* This module is marked as Global so the EmailService can be
* injected anywhere without importing the module.
*/
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View file

@ -0,0 +1,262 @@
/**
* Email Service using Brevo API
*
* Handles transactional emails for:
* - Password reset
* - Organization invitations
* - Email verification (future)
*
* @see https://developers.brevo.com/reference/sendtransacemail
*/
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as brevo from '@getbrevo/brevo';
export interface SendEmailOptions {
to: string;
subject: string;
htmlContent: string;
textContent?: string;
}
export interface PasswordResetEmailOptions {
email: string;
name?: string;
resetUrl: string;
}
export interface OrganizationInviteEmailOptions {
email: string;
organizationName: string;
inviterName?: string;
inviteUrl: string;
role: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly apiInstance: brevo.TransactionalEmailsApi;
private readonly senderEmail: string;
private readonly senderName: string;
private readonly isEnabled: boolean;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('BREVO_API_KEY');
this.senderEmail =
this.configService.get<string>('EMAIL_SENDER_ADDRESS') || 'noreply@manacore.app';
this.senderName = this.configService.get<string>('EMAIL_SENDER_NAME') || 'ManaCore';
this.isEnabled = !!apiKey;
this.apiInstance = new brevo.TransactionalEmailsApi();
if (apiKey) {
this.apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey);
this.logger.log('Brevo email service initialized');
} else {
this.logger.warn('BREVO_API_KEY not set - emails will be logged to console only');
}
}
/**
* Send a transactional email
*/
async sendEmail(options: SendEmailOptions): Promise<boolean> {
const { to, subject, htmlContent, textContent } = options;
if (!this.isEnabled) {
this.logger.log('[DEV MODE] Email would be sent:');
this.logger.log(` To: ${to}`);
this.logger.log(` Subject: ${subject}`);
this.logger.log(` Content: ${htmlContent.substring(0, 200)}...`);
return true;
}
try {
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.subject = subject;
sendSmtpEmail.htmlContent = htmlContent;
sendSmtpEmail.textContent = textContent;
sendSmtpEmail.sender = {
name: this.senderName,
email: this.senderEmail,
};
sendSmtpEmail.to = [{ email: to }];
const response = await this.apiInstance.sendTransacEmail(sendSmtpEmail);
this.logger.log(`Email sent successfully to ${to}, messageId: ${response.body.messageId}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email to ${to}:`, error);
return false;
}
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(options: PasswordResetEmailOptions): Promise<boolean> {
const { email, name, resetUrl } = options;
const displayName = name || 'there';
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 20px;">
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset Your Password</h1>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
Hi ${displayName},
</p>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
We received a request to reset the password for your ManaCore account. Click the button below to choose a new password:
</p>
<table role="presentation" style="margin: 30px 0;">
<tr>
<td>
<a href="${resetUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
Reset Password
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
If the button above doesn't work, copy and paste this URL into your browser:<br>
<a href="${resetUrl}" style="color: #6366f1; word-break: break-all;">${resetUrl}</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 40px 40px;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
&copy; ${new Date().getFullYear()} ManaCore. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const textContent = `
Reset Your Password
Hi ${displayName},
We received a request to reset the password for your ManaCore account.
Reset your password by visiting this link:
${resetUrl}
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
© ${new Date().getFullYear()} ManaCore. All rights reserved.
`;
return this.sendEmail({
to: email,
subject: 'Reset Your Password - ManaCore',
htmlContent,
textContent,
});
}
/**
* Send organization invitation email
*/
async sendOrganizationInviteEmail(options: OrganizationInviteEmailOptions): Promise<boolean> {
const { email, organizationName, inviterName, inviteUrl, role } = options;
const inviterText = inviterName ? `${inviterName} has invited you` : 'You have been invited';
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Organization Invitation</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td align="center" style="padding: 40px 0;">
<table role="presentation" style="width: 100%; max-width: 600px; border-collapse: collapse; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="padding: 40px 40px 20px;">
<h1 style="margin: 0 0 20px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You're Invited!</h1>
<p style="margin: 0 0 20px; font-size: 16px; line-height: 24px; color: #4a4a4a;">
${inviterText} to join <strong>${organizationName}</strong> on ManaCore as a <strong>${role}</strong>.
</p>
<table role="presentation" style="margin: 30px 0;">
<tr>
<td>
<a href="${inviteUrl}" style="display: inline-block; padding: 14px 28px; font-size: 16px; font-weight: 600; color: #ffffff; background-color: #6366f1; text-decoration: none; border-radius: 6px;">
Accept Invitation
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 20px; font-size: 14px; line-height: 22px; color: #6b6b6b;">
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 30px 0;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999;">
If the button above doesn't work, copy and paste this URL into your browser:<br>
<a href="${inviteUrl}" style="color: #6366f1; word-break: break-all;">${inviteUrl}</a>
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 40px 40px;">
<p style="margin: 0; font-size: 12px; line-height: 18px; color: #999999; text-align: center;">
&copy; ${new Date().getFullYear()} ManaCore. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const textContent = `
You're Invited!
${inviterText} to join ${organizationName} on ManaCore as a ${role}.
Accept your invitation by visiting this link:
${inviteUrl}
This invitation will expire in 7 days. If you don't want to join this organization, you can safely ignore this email.
© ${new Date().getFullYear()} ManaCore. All rights reserved.
`;
return this.sendEmail({
to: email,
subject: `You're invited to join ${organizationName} - ManaCore`,
htmlContent,
textContent,
});
}
}

View file

@ -0,0 +1,10 @@
export { EmailModule } from './email.module';
export { EmailService } from './email.service';
export type {
SendEmailOptions,
PasswordResetEmailOptions,
OrganizationInviteEmailOptions,
} from './email.service';
// Standalone email client for use outside NestJS DI (e.g., Better Auth config)
export { sendEmail, sendPasswordResetEmail, sendOrganizationInviteEmail } from './brevo-client';