From aacec1cce30db0e9a2b7c1ddd38f92a662fb59f1 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Tue, 17 Mar 2026 18:29:20 -0400 Subject: [PATCH] feat: integrate Resend for transactional email delivery Replace the stubbed email service with Resend API integration. Emails are sent with branded HTML templates including activation, welcome, payment failed, member invite, and password reset flows. - Install resend@6.9.4 in backend - Rewrite EmailService with Resend SDK + graceful fallback to stub mode when API key is not configured - Add branded HTML email template with CTA buttons, preheader text, and fallback URL for all email types - Add reply-to support (sales@hoaledgeriq.com in production) - Track send status (sent/failed) in shared.email_log metadata - Add RESEND_API_KEY, RESEND_FROM_ADDRESS, RESEND_REPLY_TO env vars to both docker-compose.yml and docker-compose.prod.yml - Add sendPasswordResetEmail() method for future use Co-Authored-By: Claude Opus 4.6 --- backend/package-lock.json | 77 +++++- backend/package.json | 1 + backend/src/modules/email/email.service.ts | 274 +++++++++++++++++++-- docker-compose.prod.yml | 3 + docker-compose.yml | 3 + 5 files changed, 329 insertions(+), 29 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index fa6789c..9a43a93 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hoa-ledgeriq-backend", - "version": "2026.3.11", + "version": "2026.3.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hoa-ledgeriq-backend", - "version": "2026.3.11", + "version": "2026.3.17", "dependencies": { "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", @@ -36,6 +36,7 @@ "pg": "^8.13.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "resend": "^6.9.4", "rxjs": "^7.8.1", "stripe": "^20.4.1", "typeorm": "^0.3.20", @@ -2791,6 +2792,12 @@ "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -5357,6 +5364,12 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8723,6 +8736,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -9207,6 +9226,27 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/resend": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz", + "integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.86.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9779,6 +9819,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10037,6 +10087,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz", + "integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/swagger-ui-dist": { "version": "5.17.14", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", diff --git a/backend/package.json b/backend/package.json index 08467b5..b25d581 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,7 @@ "pg": "^8.13.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", + "resend": "^6.9.4", "rxjs": "^7.8.1", "stripe": "^20.4.1", "typeorm": "^0.3.20", diff --git a/backend/src/modules/email/email.service.ts b/backend/src/modules/email/email.service.ts index cbf437c..29931b8 100644 --- a/backend/src/modules/email/email.service.ts +++ b/backend/src/modules/email/email.service.ts @@ -1,50 +1,159 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { DataSource } from 'typeorm'; +import { Resend } from 'resend'; -/** - * Stubbed email service — logs to console and stores in shared.email_log. - * Replace internals with Resend/SendGrid when ready for production. - */ @Injectable() export class EmailService { private readonly logger = new Logger(EmailService.name); + private resend: Resend | null = null; + private fromAddress: string; + private replyToAddress: string; - constructor(private dataSource: DataSource) {} + 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 body = [ - `Welcome to HOA LedgerIQ!`, - ``, - `Your organization "${businessName}" has been created.`, - `Please activate your account by clicking the link below:`, - ``, - activationUrl, - ``, - `This link expires in 72 hours.`, - ].join('\n'); + 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.log(email, subject, body, 'activation', { businessName, activationUrl }); + 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 body = `Your account is active. Log in at http://localhost to get started.`; - await this.log(email, subject, body, 'welcome', { 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 = `Payment failed for ${businessName} on HOA LedgerIQ`; - const body = `We were unable to process your payment. Please update your payment method.`; - await this.log(email, subject, body, 'payment_failed', { businessName }); + 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 body = `You've been invited to join ${orgName}. Click here to accept: ${inviteUrl}`; - await this.log(email, subject, body, 'invite_member', { orgName, inviteUrl }); + 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 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, @@ -52,10 +161,6 @@ export class EmailService { template: string, metadata: Record, ): Promise { - this.logger.log(`📧 EMAIL STUB → ${toEmail}`); - this.logger.log(` Subject: ${subject}`); - this.logger.log(` Body:\n${body}`); - try { await this.dataSource.query( `INSERT INTO shared.email_log (to_email, subject, body, template, metadata) @@ -66,4 +171,119 @@ export class EmailService { 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 +

+
+
+ +`; + } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2a62961..587f164 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -56,6 +56,9 @@ services: - WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-https://app.hoaledgeriq.com} - INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-} - APP_URL=${APP_URL:-https://app.hoaledgeriq.com} + - RESEND_API_KEY=${RESEND_API_KEY:-} + - RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com} + - RESEND_REPLY_TO=${RESEND_REPLY_TO:-sales@hoaledgeriq.com} deploy: resources: limits: diff --git a/docker-compose.yml b/docker-compose.yml index 38036e8..cd4048c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,9 @@ services: - WEBAUTHN_RP_ORIGIN=${WEBAUTHN_RP_ORIGIN:-http://localhost} - INVITE_TOKEN_SECRET=${INVITE_TOKEN_SECRET:-dev-invite-secret} - APP_URL=${APP_URL:-http://localhost} + - RESEND_API_KEY=${RESEND_API_KEY:-} + - RESEND_FROM_ADDRESS=${RESEND_FROM_ADDRESS:-noreply@hoaledgeriq.com} + - RESEND_REPLY_TO=${RESEND_REPLY_TO:-} volumes: - ./backend/src:/app/src - ./backend/nest-cli.json:/app/nest-cli.json