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