mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(auth): add Brevo email integration for password reset and org invites
This commit is contained in:
parent
f37f85eded
commit
c5ffd92bad
14 changed files with 1041 additions and 554 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
252
services/mana-core-auth/src/email/brevo-client.ts
Normal file
252
services/mana-core-auth/src/email/brevo-client.ts
Normal 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;">
|
||||
© ${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;">
|
||||
© ${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,
|
||||
});
|
||||
}
|
||||
16
services/mana-core-auth/src/email/email.module.ts
Normal file
16
services/mana-core-auth/src/email/email.module.ts
Normal 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 {}
|
||||
262
services/mana-core-auth/src/email/email.service.ts
Normal file
262
services/mana-core-auth/src/email/email.service.ts
Normal 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;">
|
||||
© ${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;">
|
||||
© ${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,
|
||||
});
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/email/index.ts
Normal file
10
services/mana-core-auth/src/email/index.ts
Normal 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue