Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318/** * Email Service * * Handles email verification, notifications, and account recovery * Supports SMTP and external providers (Resend, SendGrid, etc.) */
import crypto from 'crypto';import nodemailer from 'nodemailer';import type { Transporter } from 'nodemailer';import type { DatabaseService } from './database.js';import type { UserEmail } from '../types/email.js';
export interface EmailServiceConfig { provider: 'smtp' | 'resend' | 'sendgrid' | 'mailgun' | 'mock'; smtp?: { host: string; port: number; secure?: boolean; user?: string; pass?: string; }; apiKey?: string; from: string; codeExpiry?: number; // in seconds, default 15 minutes}
export class EmailService { private transporter: Transporter | null = null; private config: EmailServiceConfig; private codeExpiry: number;
constructor( private db: DatabaseService, config: EmailServiceConfig ) { this.config = config; this.codeExpiry = config.codeExpiry || 15 * 60; // 15 minutes default
if (config.provider === 'smtp' && config.smtp) { this.transporter = nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.secure ?? config.smtp.port === 465, auth: config.smtp.user ? { user: config.smtp.user, pass: config.smtp.pass, } : undefined, }); } else if (config.provider === 'mock') { // Mock transport for testing this.transporter = nodemailer.createTransport({ host: 'localhost', port: 1025, ignoreTLS: true, }); } }
/** * Add an email to a user's account */ async addEmail(did: string, email: string): Promise<{ success: boolean; error?: string }> { // Check if email is already registered to another user const existing = this.db.getUserByEmail(email); if (existing && existing.did !== did) { return { success: false, error: 'Email already registered to another account' }; }
// Save email (unverified) const emails = this.db.getUserEmails(did); const isPrimary = emails.length === 0;
this.db.saveUserEmail({ did, email, verified: false, is_primary: isPrimary, });
// Generate and send verification code const result = await this.sendVerificationCode(email, 'verify'); if (!result.success) { return result; }
return { success: true }; }
/** * Send verification code to email */ async sendVerificationCode( email: string, purpose: 'verify' | 'recovery' ): Promise<{ success: boolean; error?: string }> { // Generate 6-digit code const code = this.generateCode(); const codeHash = this.hashCode(code);
// Store code this.db.saveEmailVerificationCode({ email, code_hash: codeHash, purpose, expires_at: new Date(Date.now() + this.codeExpiry * 1000), used: false, });
// Send email const subject = purpose === 'verify' ? 'Verify your email address' : 'Account recovery code';
const text = purpose === 'verify' ? `Your verification code is: ${code}\n\nThis code expires in ${Math.floor(this.codeExpiry / 60)} minutes.` : `Your recovery code is: ${code}\n\nThis code expires in ${Math.floor(this.codeExpiry / 60)} minutes.\n\nIf you did not request this, please ignore this email.`;
try { await this.sendEmail(email, subject, text); return { success: true }; } catch (error) { console.error('[Email] Failed to send email:', error); return { success: false, error: 'Failed to send email' }; } }
/** * Verify email code */ verifyEmail( did: string, email: string, code: string ): { success: boolean; error?: string } { // Check lockout if (this.db.isEmailVerificationLocked(email)) { return { success: false, error: 'Too many failed attempts. Try again later.' }; }
const codeHash = this.hashCode(code); const verificationCode = this.db.getValidEmailVerificationCode(email, codeHash, 'verify');
if (!verificationCode) { // Track failed attempt const attempts = this.db.incrementEmailAttempts(email); if (attempts >= 5) { this.db.lockEmailVerification(email, Date.now() + 15 * 60 * 1000); } return { success: false, error: 'Invalid or expired verification code' }; }
// Mark code as used this.db.markEmailVerificationCodeUsed(verificationCode.id);
// Mark email as verified this.db.verifyUserEmail(did, email);
return { success: true }; }
/** * Request account recovery */ async requestRecovery(email: string): Promise<{ success: boolean; error?: string }> { // Check if email is verified const user = this.db.getUserByEmail(email); if (!user) { // Don't reveal if email exists or not return { success: true }; }
// Send recovery code return this.sendVerificationCode(email, 'recovery'); }
/** * Verify recovery code */ verifyRecovery( email: string, code: string ): { success: boolean; did?: string; error?: string } { // Check lockout if (this.db.isEmailVerificationLocked(email)) { return { success: false, error: 'Too many failed attempts. Try again later.' }; }
const codeHash = this.hashCode(code); const verificationCode = this.db.getValidEmailVerificationCode(email, codeHash, 'recovery');
if (!verificationCode) { // Track failed attempt const attempts = this.db.incrementEmailAttempts(email); if (attempts >= 5) { this.db.lockEmailVerification(email, Date.now() + 15 * 60 * 1000); } return { success: false, error: 'Invalid or expired recovery code' }; }
// Get user DID const user = this.db.getUserByEmail(email); if (!user) { return { success: false, error: 'No account found for this email' }; }
// Mark code as used this.db.markEmailVerificationCodeUsed(verificationCode.id);
return { success: true, did: user.did }; }
/** * Get user's emails */ getUserEmails(did: string): UserEmail[] { return this.db.getUserEmails(did); }
/** * Remove an email from user's account */ removeEmail(did: string, email: string): { success: boolean; error?: string } { const emails = this.db.getUserEmails(did); const emailToRemove = emails.find(e => e.email === email);
if (!emailToRemove) { return { success: false, error: 'Email not found' }; }
// Don't allow removing the primary email if there are other emails if (emailToRemove.is_primary && emails.length > 1) { return { success: false, error: 'Cannot remove primary email. Set another email as primary first.' }; }
this.db.deleteUserEmail(did, email); return { success: true }; }
/** * Set an email as primary */ setPrimaryEmail(did: string, email: string): { success: boolean; error?: string } { const emails = this.db.getUserEmails(did); const emailToSetPrimary = emails.find(e => e.email === email);
if (!emailToSetPrimary) { return { success: false, error: 'Email not found' }; }
if (!emailToSetPrimary.verified) { return { success: false, error: 'Only verified emails can be set as primary' }; }
this.db.setPrimaryEmail(did, email); return { success: true }; }
/** * Resend verification code */ async resendVerificationCode(email: string): Promise<{ success: boolean; error?: string }> { return this.sendVerificationCode(email, 'verify'); }
/** * Generate a 6-digit verification code */ private generateCode(): string { return crypto.randomInt(100000, 999999).toString(); }
/** * Hash a verification code for storage */ private hashCode(code: string): string { return crypto.createHash('sha256').update(code).digest('hex'); }
/** * Send an email */ private async sendEmail(to: string, subject: string, text: string): Promise<void> { if (this.config.provider === 'mock') { console.log(`[Email] Mock send to ${to}: ${subject}`); console.log(`[Email] ${text}`); return; }
if (!this.transporter) { throw new Error('Email transporter not configured'); }
await this.transporter.sendMail({ from: this.config.from, to, subject, text, }); }
/** * Verify email transporter is working */ async verify(): Promise<boolean> { if (!this.transporter) { return false; }
try { await this.transporter.verify(); return true; } catch { return false; } }}