feat: Simple nodemailer integration

This commit is contained in:
SauravDhakal
2026-03-07 07:38:50 +05:45
parent 496d689ec1
commit 6fc494687a
12 changed files with 266 additions and 7 deletions

45
common/emails/auth.ts Normal file
View File

@@ -0,0 +1,45 @@
export const welcomeToApp =
`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Research Shock</title>
<style>
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f4f7f9; }
.container { max-width: 600px; margin: 20px auto; background: #ffffff; padding: 40px; border-radius: 8px; box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
.logo { font-size: 24px; font-weight: bold; color: #2563eb; margin-bottom: 20px; text-align: center; }
h1 { font-size: 22px; color: #111827; }
p { margin-bottom: 20px; color: #4b5563; }
.button { display: inline-block; padding: 12px 24px; background-color: #2563eb; color: #ffffff; text-decoration: none; border-radius: 5px; font-weight: 600; transition: background 0.3s ease; }
.footer { margin-top: 30px; font-size: 12px; color: #9ca3af; text-align: center; }
.divider { height: 1px; background: #e5e7eb; margin: 30px 0; }
</style>
</head>
<body>
<div class="container">
<div class="logo">MultiTenant SaaS</div>
<h1>Welcome to the team!</h1>
<p>Hi there,</p>
<p>Thanks for signing up for <strong>MultiTenant Saas</strong>. We're excited to help you around.</p>
<p>To get started, we recommend setting up your organization profile and inviting your first team member.</p>
<div style="text-align: center; margin: 35px 0;">
<a href="http://localhost:3000" class="button">Go to Dashboard</a>
</div>
<p>If you have any questions, just reply to this email. We're here to help!</p>
<div class="divider"></div>
<p>Cheers,<br>Team</p>
<div class="footer">
&copy; 2026 App. All rights reserved.<br>
If you didn't create an account, you can safely ignore this email.
</div>
</div>
</body>
</html>
`

9
common/emails/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import { welcomeToApp } from "./auth"
const EmailTemplates = {
welcomeToApp,
}
export default EmailTemplates

8
core/mail/mail.module.ts Normal file
View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { MailService } from "./mail.service";
@Module({
providers: [MailService],
exports: [MailService]
})
export class MailModule { }

45
core/mail/mail.service.ts Normal file
View File

@@ -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<string | undefined>("MAIL_ID");
const mailPass = this.configService.get<string | undefined>("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
}
)
}
}

View File

@@ -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",

30
pnpm-lock.yaml generated
View File

@@ -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: {}

104
roadmap.md Normal file
View File

@@ -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 Architects 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.

View File

@@ -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: [

View File

@@ -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 { }

View File

@@ -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,6 +14,7 @@ export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
private readonly mailService: MailService,
) { }
async register(dto: RegisterUserRequestDTO) {
@@ -21,6 +24,11 @@ export class AuthService {
password: hashedPassword,
});
this.mailService.sendMail({
to: dto.email,
subject: "Welcome onboard",
body: EmailTemplates.welcomeToApp
})
return true;
}

View File

@@ -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

View File

@@ -152,7 +152,7 @@ export class OrganizationMembershipService {
})
}
catch (err) {
throw new NotFoundException("Join request")
throw new NotFoundException("Request")
}
}