feat: add email service and storage module + fix runtime env vars

## Runtime Environment Fix
- Updated all web app hooks.server.ts to use $env/dynamic/private
- This allows Docker containers to inject env vars at runtime
- Updated docker-compose.staging.yml with HTTPS staging domains
- Fixes Mixed Content errors when accessing staging via domains

## New Features
- Added email service to mana-core-auth for sending emails
- Added storage module to chat backend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-10 02:22:34 +01:00
parent 6239cc7749
commit 3fa7b027aa
17 changed files with 1225 additions and 78 deletions

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';
@ -27,6 +28,7 @@ import { HttpExceptionFilter } from './common/filters/http-exception.filter';
AiModule,
AuthModule,
CreditsModule,
EmailModule,
FeedbackModule,
HealthModule,
ReferralsModule,

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, sendOrganizationInvitationEmail } from '../email/email-sender';
/**
* 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,16 @@ export function createBetterAuth(databaseUrl: string) {
// Email invitation handler
async sendInvitationEmail(data) {
const { email, organization } = data;
const { email, organization, inviter } = data;
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
const invitationUrl = `${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 sendOrganizationInvitationEmail(
email,
organization.name,
invitationUrl,
inviter?.user?.name
);
},
// Custom roles and permissions

View file

@ -28,6 +28,12 @@ export default () => ({
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
},
email: {
brevoApiKey: process.env.BREVO_API_KEY || '',
fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app',
fromName: process.env.BREVO_FROM_NAME || 'Mana Core',
},
cors: {
origin: process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:3000',

View file

@ -0,0 +1,322 @@
/**
* Standalone email sender for Better Auth callbacks
*
* This module provides email sending functionality that can be used
* outside of the NestJS DI context (e.g., in Better Auth callbacks).
*/
import * as brevo from '@getbrevo/brevo';
interface EmailConfig {
apiKey?: string;
fromEmail: string;
fromName: string;
}
function getEmailConfig(): EmailConfig {
return {
apiKey: process.env.BREVO_API_KEY,
fromEmail: process.env.BREVO_FROM_EMAIL || 'noreply@manacore.app',
fromName: process.env.BREVO_FROM_NAME || 'Mana Core',
};
}
let apiInstance: brevo.TransactionalEmailsApi | null = null;
function getApiInstance(): brevo.TransactionalEmailsApi | null {
const config = getEmailConfig();
if (!config.apiKey) {
return null;
}
if (!apiInstance) {
apiInstance = new brevo.TransactionalEmailsApi();
apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, config.apiKey);
}
return apiInstance;
}
/**
* Send password reset email
*/
export async function sendPasswordResetEmail(
email: string,
resetUrl: string,
userName?: string
): Promise<void> {
const config = getEmailConfig();
const api = getApiInstance();
if (!api) {
console.log('[DEV MODE] Password reset email would be sent to:', email);
console.log('[DEV MODE] Reset URL:', resetUrl);
return;
}
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
sendSmtpEmail.to = [{ email }];
sendSmtpEmail.subject = 'Reset your Mana Core password';
sendSmtpEmail.htmlContent = getPasswordResetTemplate(resetUrl, userName);
sendSmtpEmail.textContent = `
Reset your password
Hi${userName ? ` ${userName}` : ''},
You requested to reset your password. Click the link below to set a new password:
${resetUrl}
This link will expire in 1 hour.
If you didn't request this, you can safely ignore this email.
- The Mana Core Team
`.trim();
try {
await api.sendTransacEmail(sendSmtpEmail);
console.log(`Password reset email sent to ${email}`);
} catch (error) {
console.error(`Failed to send password reset email to ${email}:`, error);
throw error;
}
}
/**
* Send organization invitation email
*/
export async function sendOrganizationInvitationEmail(
email: string,
organizationName: string,
invitationUrl: string,
inviterName?: string
): Promise<void> {
const config = getEmailConfig();
const api = getApiInstance();
if (!api) {
console.log('[DEV MODE] Invitation email would be sent to:', email);
console.log('[DEV MODE] Organization:', organizationName);
console.log('[DEV MODE] Invitation URL:', invitationUrl);
return;
}
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
sendSmtpEmail.to = [{ email }];
sendSmtpEmail.subject = `You've been invited to join ${organizationName} on Mana Core`;
sendSmtpEmail.htmlContent = getInvitationTemplate(organizationName, invitationUrl, inviterName);
sendSmtpEmail.textContent = `
You've been invited to ${organizationName}
Hi,
${inviterName ? `${inviterName} has` : 'You have been'} invited you to join ${organizationName} on Mana Core.
Click the link below to accept the invitation:
${invitationUrl}
This invitation will expire in 7 days.
- The Mana Core Team
`.trim();
try {
await api.sendTransacEmail(sendSmtpEmail);
console.log(`Invitation email sent to ${email}`);
} catch (error) {
console.error(`Failed to send invitation email to ${email}:`, error);
throw error;
}
}
/**
* Send email verification email
*/
export async function sendVerificationEmail(
email: string,
verificationUrl: string,
userName?: string
): Promise<void> {
const config = getEmailConfig();
const api = getApiInstance();
if (!api) {
console.log('[DEV MODE] Verification email would be sent to:', email);
console.log('[DEV MODE] Verification URL:', verificationUrl);
return;
}
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.sender = { email: config.fromEmail, name: config.fromName };
sendSmtpEmail.to = [{ email }];
sendSmtpEmail.subject = 'Verify your Mana Core email address';
sendSmtpEmail.htmlContent = getVerificationTemplate(verificationUrl, userName);
sendSmtpEmail.textContent = `
Verify your email address
Hi${userName ? ` ${userName}` : ''},
Please verify your email address by clicking the link below:
${verificationUrl}
This link will expire in 24 hours.
If you didn't create a Mana Core account, you can safely ignore this email.
- The Mana Core Team
`.trim();
try {
await api.sendTransacEmail(sendSmtpEmail);
console.log(`Verification email sent to ${email}`);
} catch (error) {
console.error(`Failed to send verification email to ${email}:`, error);
throw error;
}
}
function getPasswordResetTemplate(resetUrl: string, userName?: string): string {
return `
<!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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset your password</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
Hi${userName ? ` ${userName}` : ''},<br><br>
You requested to reset your password. Click the button below to set a new password:
</p>
<a href="${resetUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Reset Password
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This link will expire in 1 hour.<br>
If you didn't request this, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
function getInvitationTemplate(
organizationName: string,
invitationUrl: string,
inviterName?: string
): string {
return `
<!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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You've been invited!</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
${inviterName ? `${inviterName} has` : 'You have been'} invited you to join <strong>${organizationName}</strong> on Mana Core.
</p>
<a href="${invitationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Accept Invitation
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This invitation will expire in 7 days.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
function getVerificationTemplate(verificationUrl: string, userName?: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Verify your email</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
Hi${userName ? ` ${userName}` : ''},<br><br>
Thanks for signing up! Please verify your email address by clicking the button below:
</p>
<a href="${verificationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Verify Email
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This link will expire in 24 hours.<br>
If you didn't create a Mana Core account, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View file

@ -0,0 +1,320 @@
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 PasswordResetEmailData {
email: string;
name?: string;
resetUrl: string;
}
export interface InvitationEmailData {
email: string;
organizationName: string;
inviterName?: string;
invitationUrl: string;
}
export interface VerificationEmailData {
email: string;
name?: string;
verificationUrl: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private readonly apiInstance: brevo.TransactionalEmailsApi;
private readonly fromEmail: string;
private readonly fromName: string;
private readonly isConfigured: boolean;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('email.brevoApiKey');
this.fromEmail = this.configService.get<string>('email.fromEmail') || 'noreply@manacore.app';
this.fromName = this.configService.get<string>('email.fromName') || 'Mana Core';
this.apiInstance = new brevo.TransactionalEmailsApi();
if (apiKey) {
this.apiInstance.setApiKey(brevo.TransactionalEmailsApiApiKeys.apiKey, apiKey);
this.isConfigured = true;
this.logger.log('Email service configured with Brevo');
} else {
this.isConfigured = false;
this.logger.warn('Email service not configured - BREVO_API_KEY is missing');
}
}
/**
* Send a transactional email via Brevo
*/
async sendEmail(options: SendEmailOptions): Promise<boolean> {
if (!this.isConfigured) {
this.logger.warn(`[DEV MODE] Would send email to ${options.to}: ${options.subject}`);
this.logger.debug(`Email content: ${options.htmlContent}`);
return true;
}
try {
const sendSmtpEmail = new brevo.SendSmtpEmail();
sendSmtpEmail.sender = { email: this.fromEmail, name: this.fromName };
sendSmtpEmail.to = [{ email: options.to }];
sendSmtpEmail.subject = options.subject;
sendSmtpEmail.htmlContent = options.htmlContent;
if (options.textContent) {
sendSmtpEmail.textContent = options.textContent;
}
await this.apiInstance.sendTransacEmail(sendSmtpEmail);
this.logger.log(`Email sent successfully to ${options.to}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email to ${options.to}:`, error);
throw error;
}
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(data: PasswordResetEmailData): Promise<boolean> {
const subject = 'Reset your Mana Core password';
const htmlContent = this.getPasswordResetTemplate(data);
const textContent = `
Reset your password
Hi${data.name ? ` ${data.name}` : ''},
You requested to reset your password. Click the link below to set a new password:
${data.resetUrl}
This link will expire in 1 hour.
If you didn't request this, you can safely ignore this email.
- The Mana Core Team
`.trim();
return this.sendEmail({
to: data.email,
subject,
htmlContent,
textContent,
});
}
/**
* Send organization invitation email
*/
async sendInvitationEmail(data: InvitationEmailData): Promise<boolean> {
const subject = `You've been invited to join ${data.organizationName} on Mana Core`;
const htmlContent = this.getInvitationTemplate(data);
const textContent = `
You've been invited to ${data.organizationName}
Hi,
${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join ${data.organizationName} on Mana Core.
Click the link below to accept the invitation:
${data.invitationUrl}
This invitation will expire in 7 days.
- The Mana Core Team
`.trim();
return this.sendEmail({
to: data.email,
subject,
htmlContent,
textContent,
});
}
/**
* Send email verification email
*/
async sendVerificationEmail(data: VerificationEmailData): Promise<boolean> {
const subject = 'Verify your Mana Core email address';
const htmlContent = this.getVerificationTemplate(data);
const textContent = `
Verify your email address
Hi${data.name ? ` ${data.name}` : ''},
Please verify your email address by clicking the link below:
${data.verificationUrl}
This link will expire in 24 hours.
If you didn't create a Mana Core account, you can safely ignore this email.
- The Mana Core Team
`.trim();
return this.sendEmail({
to: data.email,
subject,
htmlContent,
textContent,
});
}
/**
* Password reset email template
*/
private getPasswordResetTemplate(data: PasswordResetEmailData): string {
return `
<!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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Reset your password</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
Hi${data.name ? ` ${data.name}` : ''},<br><br>
You requested to reset your password. Click the button below to set a new password:
</p>
<a href="${data.resetUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Reset Password
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This link will expire in 1 hour.<br>
If you didn't request this, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
/**
* Organization invitation email template
*/
private getInvitationTemplate(data: InvitationEmailData): string {
return `
<!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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">You've been invited!</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
${data.inviterName ? `${data.inviterName} has` : 'You have been'} invited you to join <strong>${data.organizationName}</strong> on Mana Core.
</p>
<a href="${data.invitationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Accept Invitation
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This invitation will expire in 7 days.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
/**
* Email verification template
*/
private getVerificationTemplate(data: VerificationEmailData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email</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 style="padding: 40px 20px;">
<table role="presentation" style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);">
<tr>
<td style="padding: 40px; text-align: center;">
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #1a1a1a;">Verify your email</h1>
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.5; color: #4a4a4a;">
Hi${data.name ? ` ${data.name}` : ''},<br><br>
Thanks for signing up! Please verify your email address by clicking the button below:
</p>
<a href="${data.verificationUrl}" style="display: inline-block; padding: 14px 32px; background-color: #6366f1; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">
Verify Email
</a>
<p style="margin: 24px 0 0; font-size: 14px; color: #6b7280;">
This link will expire in 24 hours.<br>
If you didn't create a Mana Core account, you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 40px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; border-radius: 0 0 8px 8px;">
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
© ${new Date().getFullYear()} Mana Core. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`.trim();
}
}

View file

@ -0,0 +1,2 @@
export * from './email.service';
export * from './email.module';