import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; import { Resend } from 'resend'; @Injectable() export class EmailService { private readonly logger = new Logger(EmailService.name); private resend: Resend | null = null; private fromAddress: string; private replyToAddress: string; constructor( private configService: ConfigService, private dataSource: DataSource, ) { const apiKey = this.configService.get('RESEND_API_KEY'); if (apiKey && !apiKey.includes('placeholder')) { this.resend = new Resend(apiKey); this.logger.log('Resend email service initialized'); } else { this.logger.warn('Resend not configured — emails will be logged only (stub mode)'); } this.fromAddress = this.configService.get('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com'; this.replyToAddress = this.configService.get('RESEND_REPLY_TO') || ''; } // ─── Public API ────────────────────────────────────────────── async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise { const subject = `Activate your ${businessName} account on HOA LedgerIQ`; const html = this.buildTemplate({ preheader: 'Your HOA LedgerIQ account is ready to activate.', heading: 'Welcome to HOA LedgerIQ!', body: `

Your organization ${this.esc(businessName)} has been created and is ready to go.

Click the button below to set your password and activate your account:

`, ctaText: 'Activate My Account', ctaUrl: activationUrl, footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.', }); await this.send(email, subject, html, 'activation', { businessName, activationUrl }); } async sendWelcomeEmail(email: string, businessName: string): Promise { const appUrl = this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'; const subject = `Welcome to HOA LedgerIQ — ${businessName}`; const html = this.buildTemplate({ preheader: `${businessName} is all set up on HOA LedgerIQ.`, heading: `You're all set!`, body: `

Your account for ${this.esc(businessName)} is now active.

Log in to start managing your HOA's finances, assessments, and investments — all in one place.

`, ctaText: 'Go to Dashboard', ctaUrl: `${appUrl}/dashboard`, footer: 'If you have any questions, just reply to this email and we\'ll help you get started.', }); await this.send(email, subject, html, 'welcome', { businessName }); } async sendPaymentFailedEmail(email: string, businessName: string): Promise { const subject = `Action required: Payment failed for ${businessName}`; const html = this.buildTemplate({ preheader: 'We were unable to process your payment.', heading: 'Payment Failed', body: `

We were unable to process the latest payment for ${this.esc(businessName)}.

Please update your payment method to avoid any interruption to your service.

`, ctaText: 'Update Payment Method', ctaUrl: `${this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`, footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.', }); await this.send(email, subject, html, 'payment_failed', { businessName }); } async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise { const subject = `You've been invited to ${orgName} on HOA LedgerIQ`; const html = this.buildTemplate({ preheader: `Join ${orgName} on HOA LedgerIQ.`, heading: 'You\'re Invited!', body: `

You've been invited to join ${this.esc(orgName)} on HOA LedgerIQ.

Click below to accept the invitation and set up your account:

`, ctaText: 'Accept Invitation', ctaUrl: inviteUrl, footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.', }); await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl }); } async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise { const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`; const html = this.buildTemplate({ preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`, heading: `Your Trial Ends in ${daysRemaining} Days`, body: `

Your free trial for ${this.esc(businessName)} on HOA LedgerIQ ends in ${daysRemaining} days.

To continue using all features without interruption, add a payment method before your trial expires.

If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.

`, ctaText: 'Add Payment Method', ctaUrl: settingsUrl, footer: 'If you have any questions about plans or pricing, just reply to this email.', }); await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl }); } async sendTrialExpiredEmail(email: string, businessName: string): Promise { const appUrl = this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'; const subject = `Your free trial has ended — ${businessName}`; const html = this.buildTemplate({ preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`, heading: 'Your Trial Has Ended', body: `

The free trial for ${this.esc(businessName)} on HOA LedgerIQ has ended.

Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.

`, ctaText: 'Choose a Plan', ctaUrl: `${appUrl}/pricing`, footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.', }); await this.send(email, subject, html, 'trial_expired', { businessName }); } async sendNewMemberWelcomeEmail( email: string, firstName: string, orgName: string, ): Promise { const appUrl = this.configService.get('APP_URL') || 'https://app.hoaledgeriq.com'; const subject = `Welcome to ${orgName} on HOA LedgerIQ`; const html = this.buildTemplate({ preheader: `Your account for ${orgName} on HOA LedgerIQ is ready.`, heading: `Welcome, ${this.esc(firstName)}!`, body: `

You've been added as a member of ${this.esc(orgName)} on HOA LedgerIQ.

Your account is ready to use. Log in with your email address and the temporary password provided by your administrator. You'll be able to change your password after logging in.

HOA LedgerIQ gives you access to your community's financial dashboard, budgets, reports, and more.

`, ctaText: 'Log In Now', ctaUrl: `${appUrl}/login`, footer: 'If you were not expecting this email, please contact your HOA administrator.', }); await this.send(email, subject, html, 'new_member_welcome', { orgName, firstName }); } async sendPasswordResetEmail(email: string, resetUrl: string): Promise { const subject = 'Reset your HOA LedgerIQ password'; const html = this.buildTemplate({ preheader: 'Password reset requested for your HOA LedgerIQ account.', heading: 'Password Reset', body: `

We received a request to reset your password. Click the button below to choose a new one:

`, ctaText: 'Reset Password', ctaUrl: resetUrl, footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.', }); await this.send(email, subject, html, 'password_reset', { resetUrl }); } // ─── Core send logic ──────────────────────────────────────── private async send( toEmail: string, subject: string, html: string, template: string, metadata: Record, ): Promise { // Always log to the database await this.log(toEmail, subject, html, template, metadata); if (!this.resend) { this.logger.log(`📧 EMAIL STUB → ${toEmail}`); this.logger.log(` Subject: ${subject}`); return; } try { const result = await this.resend.emails.send({ from: this.fromAddress, to: [toEmail], replyTo: this.replyToAddress || undefined, subject, html, }); if (result.error) { this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`); await this.updateLogStatus(toEmail, template, 'failed', result.error.message); } else { this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`); await this.updateLogStatus(toEmail, template, 'sent', result.data?.id); } } catch (err: any) { this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`); await this.updateLogStatus(toEmail, template, 'failed', err.message); } } // ─── Database logging ─────────────────────────────────────── private async log( toEmail: string, subject: string, body: string, template: string, metadata: Record, ): Promise { try { await this.dataSource.query( `INSERT INTO shared.email_log (to_email, subject, body, template, metadata) VALUES ($1, $2, $3, $4, $5)`, [toEmail, subject, body, template, JSON.stringify(metadata)], ); } catch (err) { this.logger.warn(`Failed to log email: ${err}`); } } private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise { try { await this.dataSource.query( `UPDATE shared.email_log SET metadata = metadata || $1::jsonb WHERE to_email = $2 AND template = $3 AND created_at = ( SELECT MAX(created_at) FROM shared.email_log WHERE to_email = $2 AND template = $3 )`, [JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template], ); } catch { // Best effort — don't block the flow } } // ─── HTML email template ──────────────────────────────────── private esc(text: string): string { return text.replace(/&/g, '&').replace(//g, '>'); } private buildTemplate(opts: { preheader: string; heading: string; body: string; ctaText: string; ctaUrl: string; footer: string; }): string { return ` ${this.esc(opts.heading)}
${this.esc(opts.preheader)}
HOA LedgerIQ

${this.esc(opts.heading)}

${opts.body}
${this.esc(opts.ctaText)}

If the button doesn't work, copy and paste this link into your browser:
${opts.ctaUrl}

${this.esc(opts.footer)}

© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs

`; } }