From 6fc494687aaf6e7b3ea3a8e343ac21c4d0ea65bf Mon Sep 17 00:00:00 2001 From: SauravDhakal Date: Sat, 7 Mar 2026 07:38:50 +0545 Subject: [PATCH] feat: Simple nodemailer integration --- common/emails/auth.ts | 45 ++++++++ common/emails/index.ts | 9 ++ core/mail/mail.module.ts | 8 ++ core/mail/mail.service.ts | 45 ++++++++ package.json | 2 + pnpm-lock.yaml | 30 +++++ roadmap.md | 104 ++++++++++++++++++ src/app.module.ts | 2 + src/auth/auth.module.ts | 10 +- src/auth/auth.service.ts | 14 ++- .../organization-membership.controller.ts | 2 +- .../organization-membership.service.ts | 2 +- 12 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 common/emails/auth.ts create mode 100644 common/emails/index.ts create mode 100644 core/mail/mail.module.ts create mode 100644 core/mail/mail.service.ts create mode 100644 roadmap.md diff --git a/common/emails/auth.ts b/common/emails/auth.ts new file mode 100644 index 0000000..b5bf18a --- /dev/null +++ b/common/emails/auth.ts @@ -0,0 +1,45 @@ +export const welcomeToApp = + ` + + + + + + Welcome to Research Shock + + + +
+ +

Welcome to the team!

+

Hi there,

+

Thanks for signing up for MultiTenant Saas. We're excited to help you around.

+

To get started, we recommend setting up your organization profile and inviting your first team member.

+ +
+ Go to Dashboard +
+ +

If you have any questions, just reply to this email. We're here to help!

+ +
+ +

Cheers,
Team

+ + +
+ + +` diff --git a/common/emails/index.ts b/common/emails/index.ts new file mode 100644 index 0000000..80560e5 --- /dev/null +++ b/common/emails/index.ts @@ -0,0 +1,9 @@ +import { welcomeToApp } from "./auth" + +const EmailTemplates = { + welcomeToApp, +} + +export default EmailTemplates + + diff --git a/core/mail/mail.module.ts b/core/mail/mail.module.ts new file mode 100644 index 0000000..286f471 --- /dev/null +++ b/core/mail/mail.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { MailService } from "./mail.service"; + +@Module({ + providers: [MailService], + exports: [MailService] +}) +export class MailModule { } diff --git a/core/mail/mail.service.ts b/core/mail/mail.service.ts new file mode 100644 index 0000000..d823f3d --- /dev/null +++ b/core/mail/mail.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as nodemailer from "nodemailer"; + +@Injectable() +export class MailService { + private transporter: nodemailer.Transporter; + private mailServiceAvailable = false; + + constructor(private readonly configService: ConfigService) { + const mailId = this.configService.get("MAIL_ID"); + const mailPass = this.configService.get("MAIL_PASS"); + + if (!mailId || !mailPass) + throw new Error("Make sure MAIL_ID and MAIL_PASS environment variables are set") + + // Use secure in prod + // TODO: A table for failed emails to retry later(actually bullmq) + this.transporter = nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 587, + secure: false, // Use true for port 465, false for port 587 + auth: { + user: mailId, + pass: mailPass, + }, + from: mailId + }); + + this.mailServiceAvailable = true; + } + + sendMail({ to, subject, body }: { to: string, subject: string, body: string }) { + if (!this.mailServiceAvailable) + throw new Error("Mail service not available") + + this.transporter.sendMail( + { + to, + subject, + html: body + } + ) + } +} diff --git a/package.json b/package.json index cc5c20c..0ad92f9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "cache-manager": "^7.2.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "nodemailer": "^8.0.1", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -54,6 +55,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", + "@types/nodemailer": "^7.0.11", "@types/pg": "^8.16.0", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d4c89..61f295d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.3 + nodemailer: + specifier: ^8.0.1 + version: 8.0.1 pg: specifier: ^8.18.0 version: 8.18.0 @@ -93,6 +96,9 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.19.10 + '@types/nodemailer': + specifier: ^7.0.11 + version: 7.0.11 '@types/pg': specifier: ^8.16.0 version: 8.16.0 @@ -751,42 +757,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1082,24 +1095,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.11': resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.11': resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.11': resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.11': resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==} @@ -1236,6 +1253,9 @@ packages: '@types/node@22.19.10': resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==} + '@types/nodemailer@7.0.11': + resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==} + '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} @@ -2984,6 +3004,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nodemailer@8.0.1: + resolution: {integrity: sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -5141,6 +5165,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@7.0.11': + dependencies: + '@types/node': 22.19.10 + '@types/pg@8.16.0': dependencies: '@types/node': 22.19.10 @@ -7179,6 +7207,8 @@ snapshots: node-releases@2.0.27: {} + nodemailer@8.0.1: {} + normalize-path@3.0.0: {} normalize-url@8.1.1: {} diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..4782c6e --- /dev/null +++ b/roadmap.md @@ -0,0 +1,104 @@ +``` + +``` md + ## Immediate + + User registers: AuthService saves the user and emits a UserSignedUpEvent. + + Listener catches it: The WelcomeEmailListener hears the event. + + Queue handles it: The listener adds a job to BullMQ called send-welcome-email. + + Worker processes it: A separate background worker picks up the job, tries to send the email, and automatically handles retries if it fails. +``` +``` + + +# 🏗️ SaaS Architect’s Roadmap: NestJS & DevOps + +This document serves as a strategic guide for evolving from a developer to an architect. It balances the "What" (the technology) with the "How" (the engineering mindset). + +--- + +## 🛠️ Phase 1: The "What" — Advanced Backend Features +*Focus: Scaling complexity and ensuring production-grade reliability.* + +### 1. Advanced Multi-Tenancy Architecture +* **The Goal:** Move beyond simple `organization_id` filters to true data isolation. +* **Concepts:** Shared Database/Separate Schema or **Row Level Security (RLS)**. +* **Challenge:** Implement a NestJS `Interceptor` or `Provider` that extracts a `tenant_id` from the request and automatically switches the database context/schema. + + +### 2. Background Tasks & Reliability +* **The Goal:** Decouple time-consuming tasks from the request/response cycle. +* **Tech Stack:** **BullMQ** + **Redis**. +* **Implementation:** Offload email invitations, image processing, and report generation to a separate "Worker" instance. + + +### 3. Real-Time Interactions +* **The Goal:** Create a reactive, living dashboard. +* **Tech Stack:** **Socket.io** (WebSockets). +* **Implementation:** Sync team membership changes or notification counts in real-time without requiring a page refresh. + +### 4. Billing & Webhooks (The SaaS "S") +* **The Goal:** Turn a project into a business. +* **Tech Stack:** **Stripe API**. +* **Learning Point:** Handle **Idempotency**. Ensure that if Stripe sends a "Payment Success" webhook twice, you only upgrade the user's account once. + +--- + +## 🧠 Phase 2: The "How" — Architectural Thinking +*Focus: Designing systems that don't break under edge cases.* + +### 1. State Machine Logic +Stop thinking in "CRUD" (Create, Read, Update, Delete) and start thinking in **Transitions**. +* **Entity Lifecycle:** An Invitation isn't just a row; it's a flow: `Pending` → `Accepted` | `Declined` | `Expired`. +* **Action:** Explicitly define what happens during every status change (e.g., "On Accept: Create User, Join Team, Clear Invite Cache"). + + +### 2. Atomic Transactions +Never perform "partial" updates. +* **Strategy:** Use Database Transactions (`BEGIN...COMMIT`) for any operation touching multiple tables. If the user joins the team but the invitation update fails, the whole thing must roll back. + +### 3. Event-Driven Side Effects +Don't bloat your services with 10 different tasks. +* **Pattern:** Use an **Event Emitter**. +* **Logic:** `UserService.create()` emits a `USER_CREATED` event. Separate listeners then handle the email, the analytics ping, and the Slack notification independently. + +--- + +## ☁️ Phase 3: The DevOps Pillar +*Focus: Automation, Observability, and "Infrastructure as Code".* + +### 1. Observability (Stop Guessing) +* **Structured Logging:** Use **Pino** to output JSON logs that include `trace_id` and `tenant_id`. +* **Tracing:** Implement **OpenTelemetry** to visualize the lifecycle of a request across different services. + + +### 2. Infrastructure as Code (IaC) +* **The Goal:** Destroy and rebuild your entire cloud environment in minutes. +* **Tech Stack:** **Terraform**. +* **Action:** Codify your GCP/Azure VMs, VPC Firewalls, and Managed Databases so you never have to click buttons in a portal manually. + +### 3. Hardened CI/CD +* **The Goal:** Catch errors before they hit production. +* **Action:** Add "Structure Verification" steps in your GitHub Actions to ensure the build folder matches what the server expects (e.g., checking for Next.js standalone chunks). + +--- + +## 🤖 The "Anti-GIGO" AI Workflow +*How to use AI as an architect, not a typist.* + +1. **Sketch First:** Define your DB schema and logic flow on a whiteboard before opening the chat. +2. **Hunt Edge Cases:** Ask yourself: "What if the user is already in another org? What if the database is locked?" +3. **Prompt as an Architect:** + * *Bad:* "Make an invite system." + * *Good:* "Design a NestJS service for an Invitation State Machine. It must use a database transaction, handle BullMQ for email side-effects, and include a unique constraint on (email, organization_id)." +4. **Audit the Output:** Check for N+1 query problems, lack of input validation, and missing error handling. + +--- + +**Next Steps for You:** +* Start with the **Invitation State Machine**. +* Implement **BullMQ** for the invitation email. +* Write your first **Terraform** script to manage your GCP firewall. diff --git a/src/app.module.ts b/src/app.module.ts index 92b6c47..dd7812c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { OrganizationModule } from './organization/organization.module'; import { OrganizationMembershipModule } from './organization-membership/organization-membership.module'; import { AuthorizationModule } from './authorization/authorization.module'; import { CacheModule } from './cache/cache.module'; +import { MailModule } from 'core/mail/mail.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { CacheModule } from './cache/cache.module'; OrganizationMembershipModule, AuthorizationModule, CacheModule, + MailModule ], controllers: [AppController], providers: [ diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index f303cdf..49b8483 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,6 +6,7 @@ import { AuthGuard } from './guards/auth.guard'; import { UserModule } from 'src/user/user.module'; import { JwtModule } from '@nestjs/jwt'; import { RequestContextModule } from 'core/als/request-context.module'; +import { MailModule } from 'core/mail/mail.module'; @Global() @Module({ @@ -17,6 +18,11 @@ import { RequestContextModule } from 'core/als/request-context.module'; }, ], controllers: [AuthController], - imports: [UserModule, JwtModule, RequestContextModule], + imports: [ + UserModule, + JwtModule, + RequestContextModule, + MailModule + ], }) -export class AuthModule {} +export class AuthModule { } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 88ed60a..94ce549 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,6 +5,8 @@ import * as bcrypt from 'bcrypt'; import { UserService } from 'src/user/user.service'; import { TokenInputType } from './types'; import { JwtService } from '@nestjs/jwt'; +import { MailService } from 'core/mail/mail.service'; +import EmailTemplates from 'common/emails'; @Injectable() @Public() @@ -12,7 +14,8 @@ export class AuthService { constructor( private readonly userService: UserService, private readonly jwtService: JwtService, - ) {} + private readonly mailService: MailService, + ) { } async register(dto: RegisterUserRequestDTO) { const hashedPassword = await bcrypt.hash(dto.password, 10); @@ -21,6 +24,11 @@ export class AuthService { password: hashedPassword, }); + this.mailService.sendMail({ + to: dto.email, + subject: "Welcome onboard", + body: EmailTemplates.welcomeToApp + }) return true; } @@ -50,9 +58,9 @@ export class AuthService { }; } - logout() {} + logout() { } - resetPassword() {} + resetPassword() { } // TODO: Use nest jwt private async genSignedTokens(token: TokenInputType) { diff --git a/src/organization-membership/organization-membership.controller.ts b/src/organization-membership/organization-membership.controller.ts index 2f307a5..949b2b9 100644 --- a/src/organization-membership/organization-membership.controller.ts +++ b/src/organization-membership/organization-membership.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseEnumPipe, ParseUUIDPipe, Pat import { OrganizationMembershipService } from './organization-membership.service'; import { RequestContextService } from 'core/als/request-context.service'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { InviteUserToOrganizationRequestDTO, JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto'; +import { JoinRequestToOrganizationRequestDTO, UserOrganizationInvitationActionRequestDTO } from './dto'; import { ORGANIZATION_JOIN_REQUEST_TYPE } from 'prisma/generated/prisma/enums'; /* NOTE: Regarding endpoint path naming diff --git a/src/organization-membership/organization-membership.service.ts b/src/organization-membership/organization-membership.service.ts index c70bd58..d3d93d2 100644 --- a/src/organization-membership/organization-membership.service.ts +++ b/src/organization-membership/organization-membership.service.ts @@ -152,7 +152,7 @@ export class OrganizationMembershipService { }) } catch (err) { - throw new NotFoundException("Join request") + throw new NotFoundException("Request") } }