Add email verification system for user registration
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
All checks were successful
Build And Push Image / docker (push) Successful in 3m1s
- Add SMTP configuration UI in admin panel with test functionality - Implement email verification workflow with tokens and templates - Add verification success/expired pages for user feedback - Include nodemailer, handlebars, and JWT dependencies - Create API endpoints for email config, testing, and verification
This commit is contained in:
46
server/api/admin/smtp-config.get.ts
Normal file
46
server/api/admin/smtp-config.get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/smtp-config.get] =========================');
|
||||
console.log('[api/admin/smtp-config.get] GET /api/admin/smtp-config - Get SMTP configuration');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/smtp-config.get] Authorized admin:', session.user.email);
|
||||
|
||||
// Get SMTP configuration
|
||||
const { getSMTPConfig } = await import('~/server/utils/admin-config');
|
||||
const config = getSMTPConfig();
|
||||
|
||||
// Hide password for security
|
||||
const safeConfig = {
|
||||
...config,
|
||||
password: config.password ? '••••••••••••••••' : ''
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: safeConfig
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/smtp-config.get] ❌ Error getting SMTP config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
91
server/api/admin/smtp-config.post.ts
Normal file
91
server/api/admin/smtp-config.post.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/smtp-config.post] =========================');
|
||||
console.log('[api/admin/smtp-config.post] POST /api/admin/smtp-config - Save SMTP configuration');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/smtp-config.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Parse request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/smtp-config.post] Request body:', {
|
||||
...body,
|
||||
password: body.password ? '••••••••••••••••' : ''
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!body.host || !body.port || !body.fromAddress || !body.fromName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Missing required SMTP configuration fields'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.fromAddress)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid from address email format'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate port is a number
|
||||
const port = parseInt(body.port, 10);
|
||||
if (isNaN(port) || port < 1 || port > 65535) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Port must be a valid number between 1 and 65535'
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare configuration object
|
||||
const smtpConfig = {
|
||||
host: body.host.trim(),
|
||||
port: port,
|
||||
secure: Boolean(body.secure),
|
||||
username: body.username?.trim() || '',
|
||||
password: body.password?.trim() || '',
|
||||
fromAddress: body.fromAddress.trim(),
|
||||
fromName: body.fromName.trim()
|
||||
};
|
||||
|
||||
console.log('[api/admin/smtp-config.post] Saving SMTP config:', {
|
||||
...smtpConfig,
|
||||
password: smtpConfig.password ? '••••••••••••••••' : ''
|
||||
});
|
||||
|
||||
// Save SMTP configuration
|
||||
const { saveSMTPConfig } = await import('~/server/utils/admin-config');
|
||||
await saveSMTPConfig(smtpConfig, session.user.email);
|
||||
|
||||
console.log('[api/admin/smtp-config.post] ✅ SMTP configuration saved successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP configuration saved successfully'
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/smtp-config.post] ❌ Error saving SMTP config:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
92
server/api/admin/test-email.post.ts
Normal file
92
server/api/admin/test-email.post.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/test-email.post] =========================');
|
||||
console.log('[api/admin/test-email.post] POST /api/admin/test-email - Send test email');
|
||||
|
||||
try {
|
||||
// Validate session and require admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
if (session.user.tier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Admin privileges required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/test-email.post] Authorized admin:', session.user.email);
|
||||
|
||||
// Parse request body
|
||||
const body = await readBody(event);
|
||||
console.log('[api/admin/test-email.post] Request body:', body);
|
||||
|
||||
// Validate required fields
|
||||
if (!body.testEmail) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Test email address is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(body.testEmail)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email address format'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/test-email.post] Sending test email to:', body.testEmail);
|
||||
|
||||
// Get email service and send test email
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const emailService = getEmailService();
|
||||
|
||||
// Verify connection first
|
||||
const connectionOk = await emailService.verifyConnection();
|
||||
if (!connectionOk) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'SMTP connection verification failed. Please check your SMTP configuration.'
|
||||
});
|
||||
}
|
||||
|
||||
// Send test email
|
||||
await emailService.sendTestEmail(body.testEmail);
|
||||
|
||||
console.log('[api/admin/test-email.post] ✅ Test email sent successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Test email sent successfully to ${body.testEmail}`
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/test-email.post] ❌ Error sending test email:', error);
|
||||
|
||||
// Provide more specific error messages for common SMTP issues
|
||||
let errorMessage = error.message || 'Failed to send test email';
|
||||
|
||||
if (error.code === 'EAUTH') {
|
||||
errorMessage = 'SMTP authentication failed. Please check your username and password.';
|
||||
} else if (error.code === 'ECONNECTION' || error.code === 'ETIMEDOUT') {
|
||||
errorMessage = 'Could not connect to SMTP server. Please check your host and port settings.';
|
||||
} else if (error.code === 'ESOCKET') {
|
||||
errorMessage = 'Socket error. Please check your network connection and SMTP settings.';
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
137
server/api/auth/send-verification-email.post.ts
Normal file
137
server/api/auth/send-verification-email.post.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { email } = body;
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Email is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Invalid email format'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[send-verification-email] Processing request for email:', email);
|
||||
|
||||
// Check if user exists in Keycloak
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const keycloak = createKeycloakAdminClient();
|
||||
|
||||
let existingUsers;
|
||||
try {
|
||||
existingUsers = await keycloak.findUserByEmail(email.toLowerCase().trim());
|
||||
} catch (error: any) {
|
||||
console.error('[send-verification-email] Failed to search users:', error.message);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to verify account status'
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingUsers || existingUsers.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'No account found with this email address'
|
||||
});
|
||||
}
|
||||
|
||||
const user = existingUsers[0];
|
||||
|
||||
// Check if user is already verified
|
||||
if (user.emailVerified) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'This email address is already verified'
|
||||
});
|
||||
}
|
||||
|
||||
// Rate limiting: check if we recently sent an email to this address
|
||||
const rateLimitKey = `verification_email_${email.toLowerCase()}`;
|
||||
|
||||
// Simple in-memory rate limiting (in production, use Redis)
|
||||
const globalCache = globalThis as any;
|
||||
if (!globalCache.verificationEmailCache) {
|
||||
globalCache.verificationEmailCache = new Map();
|
||||
}
|
||||
|
||||
const lastSent = globalCache.verificationEmailCache.get(rateLimitKey);
|
||||
const cooldownPeriod = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
if (lastSent && Date.now() - lastSent < cooldownPeriod) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage: 'Please wait a few minutes before requesting another verification email'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
const verificationToken = await generateEmailVerificationToken(user.id, email);
|
||||
|
||||
// Get configuration
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
// Send verification email
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const emailService = getEmailService();
|
||||
|
||||
try {
|
||||
await emailService.sendWelcomeEmail(email, {
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
verificationLink,
|
||||
memberId: user.id
|
||||
});
|
||||
|
||||
console.log('[send-verification-email] Successfully sent verification email to:', email);
|
||||
|
||||
// Update rate limiting cache
|
||||
globalCache.verificationEmailCache.set(rateLimitKey, Date.now());
|
||||
|
||||
// Clean up old rate limit entries periodically
|
||||
if (Math.random() < 0.1) { // 10% chance
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of globalCache.verificationEmailCache.entries()) {
|
||||
if (now - timestamp > cooldownPeriod * 2) {
|
||||
globalCache.verificationEmailCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Verification email sent successfully'
|
||||
};
|
||||
|
||||
} catch (emailError: any) {
|
||||
console.error('[send-verification-email] Failed to send email:', emailError.message);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to send verification email. Please try again later.'
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[send-verification-email] Request failed:', error.message);
|
||||
|
||||
// Re-throw HTTP errors
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle unexpected errors
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'An unexpected error occurred. Please try again.'
|
||||
});
|
||||
}
|
||||
});
|
||||
59
server/api/auth/verify-email.get.ts
Normal file
59
server/api/auth/verify-email.get.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const { token } = getQuery(event);
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Verification token is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[verify-email] Processing verification token...');
|
||||
|
||||
// Verify the token
|
||||
const { verifyEmailToken } = await import('~/server/utils/email-tokens');
|
||||
const { userId, email } = await verifyEmailToken(token);
|
||||
|
||||
// Update user verification status in Keycloak
|
||||
const { createKeycloakAdminClient } = await import('~/server/utils/keycloak-admin');
|
||||
const keycloak = createKeycloakAdminClient();
|
||||
|
||||
try {
|
||||
await keycloak.updateUserProfile(userId, {
|
||||
emailVerified: true,
|
||||
attributes: {
|
||||
lastLoginDate: new Date().toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[verify-email] Successfully verified user:', userId, 'email:', email);
|
||||
|
||||
// Redirect to success page
|
||||
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email), 302);
|
||||
|
||||
} catch (keycloakError: any) {
|
||||
console.error('[verify-email] Keycloak update failed:', keycloakError.message);
|
||||
|
||||
// Even if Keycloak update fails, consider verification successful if token was valid
|
||||
// This prevents user frustration due to backend issues
|
||||
return sendRedirect(event, '/auth/verify-success?email=' + encodeURIComponent(email) + '&warning=partial', 302);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[verify-email] Verification failed:', error.message);
|
||||
|
||||
// Handle different error types with appropriate redirects
|
||||
if (error.message?.includes('expired')) {
|
||||
return sendRedirect(event, '/auth/verify-expired', 302);
|
||||
} else if (error.message?.includes('already used') || error.message?.includes('not found')) {
|
||||
return sendRedirect(event, '/auth/verify-expired?reason=used', 302);
|
||||
} else {
|
||||
// For other errors, show a generic error page
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: error.message || 'Invalid verification link'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -111,11 +111,35 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Created Keycloak user with ID:', keycloakId);
|
||||
|
||||
// 6. Update member record with keycloak_id
|
||||
// 8. Update member record with keycloak_id
|
||||
console.log('[api/members/[id]/create-portal-account.post] Updating member record with keycloak_id...');
|
||||
const { updateMember } = await import('~/server/utils/nocodb');
|
||||
await updateMember(memberId, { keycloak_id: keycloakId });
|
||||
|
||||
// 9. Send welcome/verification email using our custom email system
|
||||
console.log('[api/members/[id]/create-portal-account.post] Sending welcome/verification email...');
|
||||
try {
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
|
||||
const emailService = getEmailService();
|
||||
const verificationToken = await generateEmailVerificationToken(keycloakId, member.email);
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
await emailService.sendWelcomeEmail(member.email, {
|
||||
firstName: member.first_name,
|
||||
lastName: member.last_name,
|
||||
verificationLink,
|
||||
memberId: memberId
|
||||
});
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] Welcome email sent successfully');
|
||||
} catch (emailError: any) {
|
||||
console.error('[api/members/[id]/create-portal-account.post] Failed to send welcome email:', emailError.message);
|
||||
// Don't fail the account creation if email fails - user can resend verification email later
|
||||
}
|
||||
|
||||
console.log('[api/members/[id]/create-portal-account.post] ✅ Portal account creation successful');
|
||||
|
||||
return {
|
||||
|
||||
@@ -135,6 +135,30 @@ export default defineEventHandler(async (event) => {
|
||||
const member = await nocodb.create('members', memberData);
|
||||
createdMemberId = member.Id;
|
||||
|
||||
// 7. Send welcome/verification email using our custom email system
|
||||
console.log('[api/registration.post] Sending welcome/verification email...');
|
||||
try {
|
||||
const { getEmailService } = await import('~/server/utils/email');
|
||||
const { generateEmailVerificationToken } = await import('~/server/utils/email-tokens');
|
||||
|
||||
const emailService = getEmailService();
|
||||
const verificationToken = await generateEmailVerificationToken(createdKeycloakId, body.email);
|
||||
const config = useRuntimeConfig();
|
||||
const verificationLink = `${config.public.domain}/api/auth/verify-email?token=${verificationToken}`;
|
||||
|
||||
await emailService.sendWelcomeEmail(body.email, {
|
||||
firstName: body.first_name,
|
||||
lastName: body.last_name,
|
||||
verificationLink,
|
||||
memberId: createdMemberId
|
||||
});
|
||||
|
||||
console.log('[api/registration.post] Welcome email sent successfully');
|
||||
} catch (emailError: any) {
|
||||
console.error('[api/registration.post] Failed to send welcome email:', emailError.message);
|
||||
// Don't fail the registration if email fails - user can resend verification email later
|
||||
}
|
||||
|
||||
console.log(`[api/registration.post] ✅ Registration successful - Member ID: ${createdMemberId}, Keycloak ID: ${createdKeycloakId}`);
|
||||
|
||||
return {
|
||||
|
||||
216
server/templates/test.hbs
Normal file
216
server/templates/test.hbs
Normal file
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SMTP Test Email</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #a31515;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #a31515;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.success-badge {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 25px;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.test-details {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #a31515;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.test-details h3 {
|
||||
color: #a31515;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #333;
|
||||
font-family: 'Courier New', monospace;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #a31515;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
min-width: auto;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
|
||||
<h1 class="title">SMTP Test Email</h1>
|
||||
<p class="subtitle">Email Configuration Test</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<div class="success-badge">
|
||||
✅ SMTP Configuration Working!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>Congratulations! This test email confirms that your SMTP email configuration is working correctly. The MonacoUSA Portal email system is now ready to send emails.</p>
|
||||
|
||||
<div class="test-details">
|
||||
<h3>📊 Test Configuration Details</h3>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Test Time:</div>
|
||||
<div class="detail-value">{{testTime}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">SMTP Host:</div>
|
||||
<div class="detail-value">{{smtpHost}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">From Address:</div>
|
||||
<div class="detail-value">{{fromAddress}}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Email Type:</div>
|
||||
<div class="detail-value">SMTP Configuration Test</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>🎯 What This Means</h3>
|
||||
<ul>
|
||||
<li><strong>✅ Connection Established:</strong> Successfully connected to your SMTP server</li>
|
||||
<li><strong>✅ Authentication Passed:</strong> SMTP credentials are correct and working</li>
|
||||
<li><strong>✅ Email Delivery:</strong> Emails can be sent from the portal</li>
|
||||
<li><strong>✅ Template System:</strong> Email templates are loading and rendering correctly</li>
|
||||
</ul>
|
||||
|
||||
<h3>📧 Available Email Types</h3>
|
||||
<p>Your portal can now send the following types of emails:</p>
|
||||
<ul>
|
||||
<li><strong>Welcome Emails:</strong> New member registration confirmations</li>
|
||||
<li><strong>Email Verification:</strong> Account activation links</li>
|
||||
<li><strong>Password Reset:</strong> Secure password reset instructions</li>
|
||||
<li><strong>Dues Reminders:</strong> Membership payment notifications</li>
|
||||
<li><strong>General Notifications:</strong> Administrative communications</li>
|
||||
</ul>
|
||||
|
||||
<div style="background: rgba(40, 167, 69, 0.1); padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;">
|
||||
<p style="margin: 0; color: #155724;">
|
||||
<strong>✨ Success!</strong> Your email system is fully configured and operational.
|
||||
All automated emails from the MonacoUSA Portal will now be delivered successfully.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>MonacoUSA Portal</strong><br>
|
||||
Email System Configuration Test</p>
|
||||
|
||||
<p>
|
||||
<a href="https://portal.monacousa.org" class="link">Portal</a> |
|
||||
<a href="mailto:admin@monacousa.org" class="link">Admin Support</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 20px; font-size: 12px; color: #999;">
|
||||
This is an automated test email to verify SMTP configuration.<br>
|
||||
Generated by MonacoUSA Portal Email System
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
219
server/templates/welcome.hbs
Normal file
219
server/templates/welcome.hbs
Normal file
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Welcome to MonacoUSA</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #a31515;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #a31515;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
font-size: 16px;
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 20px;
|
||||
color: #a31515;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #a31515;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.member-info h3 {
|
||||
color: #a31515;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.verify-button {
|
||||
display: inline-block;
|
||||
padding: 15px 30px;
|
||||
background: linear-gradient(135deg, #a31515 0%, #c41e1e 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.verify-button:hover {
|
||||
background: linear-gradient(135deg, #8b1212 0%, #a31515 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(163, 21, 21, 0.3);
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
background: rgba(163, 21, 21, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.payment-info h3 {
|
||||
color: #a31515;
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #a31515;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.verify-button {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<img src="{{logoUrl}}" alt="MonacoUSA" class="logo">
|
||||
<h1 class="title">Welcome to MonacoUSA</h1>
|
||||
<p class="subtitle">Monaco - United States Association</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">
|
||||
Dear {{firstName}} {{lastName}},
|
||||
</div>
|
||||
|
||||
<p>Thank you for registering to become a member of the <strong>MonacoUSA Association</strong>! We're excited to welcome you to our community that bridges Monaco and the United States.</p>
|
||||
|
||||
<div class="member-info">
|
||||
<h3>🎉 Registration Successful</h3>
|
||||
<p><strong>Member ID:</strong> {{memberId}}</p>
|
||||
<p><strong>Registration Date:</strong> {{registrationDate}}</p>
|
||||
<p>Your membership application has been received and is being processed.</p>
|
||||
</div>
|
||||
|
||||
<h3>📧 Next Step: Verify Your Email</h3>
|
||||
<p>To complete your registration and activate your account, please verify your email address by clicking the button below:</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="{{verificationLink}}" class="verify-button">
|
||||
✉️ Verify Email Address
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p><em>This verification link will expire in 24 hours for security purposes.</em></p>
|
||||
|
||||
<div class="payment-info">
|
||||
<h3>💳 Membership Dues Payment</h3>
|
||||
<p>Once your email is verified, you'll be able to log in to the portal. To activate your membership, please transfer your annual membership dues:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Amount:</strong> €50/year</li>
|
||||
<li><strong>Payment method:</strong> Bank transfer (details in your portal)</li>
|
||||
<li><strong>Status:</strong> Your account will be activated once payment is verified</li>
|
||||
</ul>
|
||||
|
||||
<p><em>You can find complete payment instructions in your member portal after verification.</em></p>
|
||||
</div>
|
||||
|
||||
<h3>🌟 What's Next?</h3>
|
||||
<ol>
|
||||
<li><strong>Verify your email</strong> using the button above</li>
|
||||
<li><strong>Log in to your portal</strong> at <a href="https://portal.monacousa.org" class="link">portal.monacousa.org</a></li>
|
||||
<li><strong>Complete your payment</strong> to activate your membership</li>
|
||||
<li><strong>Enjoy member benefits</strong> and connect with our community</li>
|
||||
</ol>
|
||||
|
||||
<p>If you have any questions, please don't hesitate to contact us. We're here to help!</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>MonacoUSA Association</strong><br>
|
||||
Connecting Monaco and the United States</p>
|
||||
|
||||
<p>
|
||||
<a href="https://portal.monacousa.org" class="link">Portal</a> |
|
||||
<a href="mailto:info@monacousa.org" class="link">Contact Us</a> |
|
||||
<a href="https://monacousa.org" class="link">Website</a>
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 20px; font-size: 12px; color: #999;">
|
||||
This email was sent to {{email}} regarding your MonacoUSA membership registration.<br>
|
||||
If you did not register for this account, please ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@ import { readFile, writeFile, mkdir, access, constants } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
|
||||
import type { NocoDBSettings } from '~/utils/types';
|
||||
import type { NocoDBSettings, SMTPConfig } from '~/utils/types';
|
||||
|
||||
interface AdminConfiguration {
|
||||
nocodb: NocoDBSettings;
|
||||
@@ -15,6 +15,15 @@ interface AdminConfiguration {
|
||||
iban: string;
|
||||
accountHolder: string;
|
||||
};
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string; // Will be encrypted
|
||||
fromAddress: string;
|
||||
fromName: string;
|
||||
};
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
@@ -189,6 +198,9 @@ export async function loadAdminConfig(): Promise<AdminConfiguration | null> {
|
||||
if (config.recaptcha?.secretKey) {
|
||||
config.recaptcha.secretKey = decryptSensitiveData(config.recaptcha.secretKey);
|
||||
}
|
||||
if (config.smtp?.password) {
|
||||
config.smtp.password = decryptSensitiveData(config.smtp.password);
|
||||
}
|
||||
|
||||
console.log('[admin-config] Configuration loaded from file');
|
||||
configCache = config;
|
||||
@@ -409,6 +421,65 @@ export function getRegistrationConfig(): { membershipFee: number; iban: string;
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save SMTP configuration
|
||||
*/
|
||||
export async function saveSMTPConfig(config: SMTPConfig, updatedBy: string): Promise<void> {
|
||||
try {
|
||||
await ensureConfigDir();
|
||||
await createBackup();
|
||||
|
||||
const currentConfig = configCache || await loadAdminConfig() || {
|
||||
nocodb: { url: '', apiKey: '', baseId: '', tables: {} },
|
||||
lastUpdated: new Date().toISOString(),
|
||||
updatedBy: 'system'
|
||||
};
|
||||
|
||||
const updatedConfig: AdminConfiguration = {
|
||||
...currentConfig,
|
||||
smtp: {
|
||||
...config,
|
||||
password: encryptSensitiveData(config.password)
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
updatedBy
|
||||
};
|
||||
|
||||
const configJson = JSON.stringify(updatedConfig, null, 2);
|
||||
await writeFile(CONFIG_FILE, configJson, 'utf-8');
|
||||
|
||||
// Update cache with unencrypted data
|
||||
configCache = {
|
||||
...updatedConfig,
|
||||
smtp: { ...config } // Keep original unencrypted data in cache
|
||||
};
|
||||
|
||||
console.log('[admin-config] SMTP configuration saved');
|
||||
await logConfigChange('SMTP_CONFIG_SAVED', updatedBy, { host: config.host, fromAddress: config.fromAddress });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[admin-config] Failed to save SMTP configuration:', error);
|
||||
await logConfigChange('SMTP_CONFIG_SAVE_FAILED', updatedBy, { error: error instanceof Error ? error.message : String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SMTP configuration
|
||||
*/
|
||||
export function getSMTPConfig(): SMTPConfig {
|
||||
const config = configCache?.smtp || {
|
||||
host: '',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: '',
|
||||
password: '',
|
||||
fromAddress: '',
|
||||
fromName: 'MonacoUSA Portal'
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize configuration system on server startup
|
||||
*/
|
||||
|
||||
168
server/utils/email-tokens.ts
Normal file
168
server/utils/email-tokens.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { sign, verify } from 'jsonwebtoken';
|
||||
|
||||
export interface EmailVerificationTokenPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
purpose: 'email-verification';
|
||||
iat: number;
|
||||
}
|
||||
|
||||
// In-memory token storage for validation (in production, consider Redis)
|
||||
const activeTokens = new Map<string, EmailVerificationTokenPayload>();
|
||||
|
||||
/**
|
||||
* Generate a secure JWT token for email verification
|
||||
*/
|
||||
export async function generateEmailVerificationToken(userId: string, email: string): Promise<string> {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
|
||||
if (!runtimeConfig.jwtSecret) {
|
||||
throw new Error('JWT secret not configured');
|
||||
}
|
||||
|
||||
const payload: EmailVerificationTokenPayload = {
|
||||
userId,
|
||||
email: email.toLowerCase().trim(),
|
||||
purpose: 'email-verification',
|
||||
iat: Date.now()
|
||||
};
|
||||
|
||||
const token = sign(payload, runtimeConfig.jwtSecret, {
|
||||
expiresIn: '24h',
|
||||
issuer: 'monacousa-portal',
|
||||
audience: 'email-verification'
|
||||
});
|
||||
|
||||
// Store token metadata for additional validation
|
||||
activeTokens.set(token, payload);
|
||||
|
||||
// Clean up expired tokens periodically
|
||||
setTimeout(() => {
|
||||
activeTokens.delete(token);
|
||||
}, 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
console.log('[email-tokens] Generated verification token for user:', userId, 'email:', email);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode an email verification token
|
||||
*/
|
||||
export async function verifyEmailToken(token: string): Promise<{ userId: string; email: string }> {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
|
||||
if (!runtimeConfig.jwtSecret) {
|
||||
throw new Error('JWT secret not configured');
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Token is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify JWT signature and expiration
|
||||
const decoded = verify(token, runtimeConfig.jwtSecret, {
|
||||
issuer: 'monacousa-portal',
|
||||
audience: 'email-verification'
|
||||
}) as EmailVerificationTokenPayload;
|
||||
|
||||
// Validate token purpose
|
||||
if (decoded.purpose !== 'email-verification') {
|
||||
throw new Error('Invalid token purpose');
|
||||
}
|
||||
|
||||
// Check if token exists in our active tokens (prevents replay attacks)
|
||||
const storedPayload = activeTokens.get(token);
|
||||
if (!storedPayload) {
|
||||
throw new Error('Token not found or already used');
|
||||
}
|
||||
|
||||
// Validate payload consistency
|
||||
if (storedPayload.userId !== decoded.userId || storedPayload.email !== decoded.email) {
|
||||
throw new Error('Token payload mismatch');
|
||||
}
|
||||
|
||||
// Remove token after successful verification (single use)
|
||||
activeTokens.delete(token);
|
||||
|
||||
console.log('[email-tokens] Successfully verified token for user:', decoded.userId, 'email:', decoded.email);
|
||||
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
email: decoded.email
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[email-tokens] Token verification failed:', error.message);
|
||||
|
||||
// Provide user-friendly error messages
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
throw new Error('Verification link has expired. Please request a new one.');
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
throw new Error('Invalid verification link.');
|
||||
} else {
|
||||
throw new Error(error.message || 'Token verification failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is still valid without consuming it
|
||||
*/
|
||||
export async function isTokenValid(token: string): Promise<boolean> {
|
||||
try {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
|
||||
if (!runtimeConfig.jwtSecret || !token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = verify(token, runtimeConfig.jwtSecret, {
|
||||
issuer: 'monacousa-portal',
|
||||
audience: 'email-verification'
|
||||
}) as EmailVerificationTokenPayload;
|
||||
|
||||
return decoded.purpose === 'email-verification' && activeTokens.has(token);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens from memory
|
||||
*/
|
||||
export function cleanupExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
const expirationTime = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
for (const [token, payload] of activeTokens.entries()) {
|
||||
if (now - payload.iat > expirationTime) {
|
||||
activeTokens.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[email-tokens] Cleaned up expired tokens. Active tokens:', activeTokens.size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about active tokens
|
||||
*/
|
||||
export function getTokenStats(): { activeTokens: number; oldestToken: number | null } {
|
||||
const now = Date.now();
|
||||
let oldestToken: number | null = null;
|
||||
|
||||
for (const payload of activeTokens.values()) {
|
||||
if (oldestToken === null || payload.iat < oldestToken) {
|
||||
oldestToken = payload.iat;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeTokens: activeTokens.size,
|
||||
oldestToken: oldestToken ? Math.floor((now - oldestToken) / 1000 / 60) : null // minutes ago
|
||||
};
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired tokens (every hour)
|
||||
setInterval(cleanupExpiredTokens, 60 * 60 * 1000);
|
||||
356
server/utils/email.ts
Normal file
356
server/utils/email.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import handlebars from 'handlebars';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { SMTPConfig } from '~/utils/types';
|
||||
|
||||
export interface EmailData {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface WelcomeEmailData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
verificationLink: string;
|
||||
memberId: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export interface VerificationEmailData {
|
||||
firstName: string;
|
||||
verificationLink: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetEmailData {
|
||||
firstName: string;
|
||||
resetLink: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export interface DuesReminderEmailData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
iban?: string;
|
||||
accountHolder?: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
private transporter: nodemailer.Transporter | null = null;
|
||||
private templates: Map<string, handlebars.TemplateDelegate> = new Map();
|
||||
|
||||
constructor(private config: SMTPConfig) {
|
||||
this.initializeTransporter();
|
||||
this.preloadTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the nodemailer transporter
|
||||
*/
|
||||
private initializeTransporter(): void {
|
||||
if (!this.config.host || !this.config.port) {
|
||||
console.warn('[EmailService] SMTP configuration incomplete, emails will not be sent');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
secure: this.config.secure, // true for 465, false for other ports
|
||||
auth: this.config.username && this.config.password ? {
|
||||
user: this.config.username,
|
||||
pass: this.config.password
|
||||
} : undefined,
|
||||
tls: {
|
||||
rejectUnauthorized: false // Accept self-signed certificates in development
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[EmailService] ✅ SMTP transporter initialized');
|
||||
} catch (error) {
|
||||
console.error('[EmailService] ❌ Failed to initialize SMTP transporter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload and compile email templates
|
||||
*/
|
||||
private preloadTemplates(): void {
|
||||
const templateNames = ['welcome', 'verification', 'password-reset', 'dues-reminder', 'test'];
|
||||
|
||||
templateNames.forEach(templateName => {
|
||||
try {
|
||||
const templatePath = join(process.cwd(), 'server/templates', `${templateName}.hbs`);
|
||||
const templateContent = readFileSync(templatePath, 'utf-8');
|
||||
const compiledTemplate = handlebars.compile(templateContent);
|
||||
this.templates.set(templateName, compiledTemplate);
|
||||
console.log(`[EmailService] ✅ Template '${templateName}' loaded`);
|
||||
} catch (error) {
|
||||
console.warn(`[EmailService] ⚠️ Template '${templateName}' not found or failed to load:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a compiled template by name
|
||||
*/
|
||||
private getTemplate(templateName: string): handlebars.TemplateDelegate | null {
|
||||
return this.templates.get(templateName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a generic email
|
||||
*/
|
||||
private async sendEmail(emailData: EmailData): Promise<void> {
|
||||
if (!this.transporter) {
|
||||
throw new Error('SMTP transporter not initialized');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `${this.config.fromName} <${this.config.fromAddress}>`,
|
||||
to: emailData.to,
|
||||
subject: emailData.subject,
|
||||
html: emailData.html,
|
||||
text: emailData.text || undefined
|
||||
};
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
console.log(`[EmailService] ✅ Email sent successfully to ${emailData.to}:`, info.messageId);
|
||||
} catch (error) {
|
||||
console.error(`[EmailService] ❌ Failed to send email to ${emailData.to}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome/verification email to new members
|
||||
*/
|
||||
async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise<void> {
|
||||
const template = this.getTemplate('welcome');
|
||||
if (!template) {
|
||||
throw new Error('Welcome email template not found');
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
...data,
|
||||
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||
};
|
||||
|
||||
const html = template(templateData);
|
||||
|
||||
await this.sendEmail({
|
||||
to,
|
||||
subject: 'Welcome to MonacoUSA - Please Verify Your Email',
|
||||
html
|
||||
});
|
||||
|
||||
console.log(`[EmailService] ✅ Welcome email sent to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification email
|
||||
*/
|
||||
async sendVerificationEmail(to: string, data: VerificationEmailData): Promise<void> {
|
||||
const template = this.getTemplate('verification');
|
||||
if (!template) {
|
||||
throw new Error('Verification email template not found');
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
...data,
|
||||
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||
};
|
||||
|
||||
const html = template(templateData);
|
||||
|
||||
await this.sendEmail({
|
||||
to,
|
||||
subject: 'Verify Your Email - MonacoUSA Portal',
|
||||
html
|
||||
});
|
||||
|
||||
console.log(`[EmailService] ✅ Verification email sent to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
|
||||
const template = this.getTemplate('password-reset');
|
||||
if (!template) {
|
||||
throw new Error('Password reset email template not found');
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
...data,
|
||||
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||
};
|
||||
|
||||
const html = template(templateData);
|
||||
|
||||
await this.sendEmail({
|
||||
to,
|
||||
subject: 'Reset Your Password - MonacoUSA Portal',
|
||||
html
|
||||
});
|
||||
|
||||
console.log(`[EmailService] ✅ Password reset email sent to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send membership dues reminder email
|
||||
*/
|
||||
async sendDuesReminderEmail(to: string, data: DuesReminderEmailData): Promise<void> {
|
||||
const template = this.getTemplate('dues-reminder');
|
||||
if (!template) {
|
||||
throw new Error('Dues reminder email template not found');
|
||||
}
|
||||
|
||||
const templateData = {
|
||||
...data,
|
||||
logoUrl: data.logoUrl || `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`
|
||||
};
|
||||
|
||||
const html = template(templateData);
|
||||
|
||||
await this.sendEmail({
|
||||
to,
|
||||
subject: `MonacoUSA Membership Dues Reminder - €${data.amount} Due`,
|
||||
html
|
||||
});
|
||||
|
||||
console.log(`[EmailService] ✅ Dues reminder email sent to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send test email to verify SMTP configuration
|
||||
*/
|
||||
async sendTestEmail(to: string): Promise<void> {
|
||||
const template = this.getTemplate('test');
|
||||
|
||||
const templateData = {
|
||||
testTime: new Date().toISOString(),
|
||||
logoUrl: `${useRuntimeConfig().public.domain}/MONACOUSA-Flags_376x376.png`,
|
||||
smtpHost: this.config.host,
|
||||
fromAddress: this.config.fromAddress
|
||||
};
|
||||
|
||||
let html: string;
|
||||
if (template) {
|
||||
html = template(templateData);
|
||||
} else {
|
||||
// Fallback HTML if template is not available
|
||||
html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>SMTP Test Email</title></head>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="text-align: center; margin-bottom: 30px;">
|
||||
<img src="${templateData.logoUrl}" alt="MonacoUSA" style="width: 100px;">
|
||||
<h1 style="color: #a31515;">SMTP Test Email</h1>
|
||||
</div>
|
||||
|
||||
<p>This is a test email from the MonacoUSA Portal email system.</p>
|
||||
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p><strong>Test Details:</strong></p>
|
||||
<ul>
|
||||
<li>Sent at: ${templateData.testTime}</li>
|
||||
<li>SMTP Host: ${templateData.smtpHost}</li>
|
||||
<li>From Address: ${templateData.fromAddress}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you received this email, your SMTP configuration is working correctly!</p>
|
||||
|
||||
<hr style="margin: 30px 0;">
|
||||
<p style="color: #666; font-size: 14px; text-align: center;">
|
||||
MonacoUSA Portal Email System
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
await this.sendEmail({
|
||||
to,
|
||||
subject: 'SMTP Configuration Test - MonacoUSA Portal',
|
||||
html,
|
||||
text: `This is a test email from MonacoUSA Portal. Sent at: ${templateData.testTime}`
|
||||
});
|
||||
|
||||
console.log(`[EmailService] ✅ Test email sent to ${to}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify SMTP connection
|
||||
*/
|
||||
async verifyConnection(): Promise<boolean> {
|
||||
if (!this.transporter) {
|
||||
throw new Error('SMTP transporter not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter.verify();
|
||||
console.log('[EmailService] ✅ SMTP connection verified');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[EmailService] ❌ SMTP connection verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SMTP configuration and reinitialize transporter
|
||||
*/
|
||||
updateConfig(newConfig: SMTPConfig): void {
|
||||
this.config = newConfig;
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the transporter connection
|
||||
*/
|
||||
close(): void {
|
||||
if (this.transporter) {
|
||||
this.transporter.close();
|
||||
this.transporter = null;
|
||||
console.log('[EmailService] SMTP connection closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for reuse across the application
|
||||
let emailServiceInstance: EmailService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create EmailService instance with current SMTP config
|
||||
*/
|
||||
export function getEmailService(): EmailService {
|
||||
const { getSMTPConfig } = require('./admin-config');
|
||||
const config = getSMTPConfig();
|
||||
|
||||
if (!emailServiceInstance) {
|
||||
emailServiceInstance = new EmailService(config);
|
||||
} else {
|
||||
// Update config in case it changed
|
||||
emailServiceInstance.updateConfig(config);
|
||||
}
|
||||
|
||||
return emailServiceInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new EmailService instance with custom config
|
||||
*/
|
||||
export function createEmailService(config: SMTPConfig): EmailService {
|
||||
return new EmailService(config);
|
||||
}
|
||||
Reference in New Issue
Block a user