Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: emails #10

Merged
merged 23 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b32de93
feat: add email functionality
typeWolffo Jul 22, 2024
e279bc3
feat: integrate email module into auth module
typeWolffo Jul 22, 2024
c9e7c42
feat: add nodemailer adapter and email configuration
typeWolffo Jul 22, 2024
7f50fd2
feat: add email adapter factory and local adapter
typeWolffo Jul 23, 2024
48e42c6
feat: add EmailTestingAdapter for testing email functionality
typeWolffo Jul 23, 2024
c65b1ba
refactor: update email adapter type and configuration schema
typeWolffo Jul 23, 2024
46a9c29
feat: add sas email adapter
typeWolffo Jul 23, 2024
90cd7ce
feat: add react email templates
typeWolffo Jul 23, 2024
c2a380a
feat: react emails package
typeWolffo Jul 24, 2024
8621ad8
refactor: update AST parsing for email templates
typeWolffo Jul 24, 2024
5731c84
feat: implement email adapter changes
typeWolffo Jul 30, 2024
0e6fe0f
refactor: update email interface and AuthService tests
typeWolffo Jul 30, 2024
7f031e5
test: refactor AuthService tests to use EmailTestingAdapter
typeWolffo Jul 30, 2024
8d1dcdc
feat: add and use EmailAdapter in tests
typeWolffo Jul 30, 2024
50282dc
feat: update EmailAdapterFactory to dynamically create email adapter
typeWolffo Jul 30, 2024
7e9485e
feat: add config validation for email and database
typeWolffo Jul 31, 2024
5c6fc6b
feat: refactor email configuration to use configValidator
typeWolffo Jul 31, 2024
086c024
feat: create configValidator for validating configuration
typeWolffo Jul 31, 2024
c17047e
feat: add config validation for jwt
typeWolffo Jul 31, 2024
40be8e9
feat: update turbo.json for build and lint tasks
typeWolffo Jul 31, 2024
af86c98
chore: add clean option to tsup configuration
typeWolffo Jul 31, 2024
fc3696c
chore: update package.json scripts
typeWolffo Aug 1, 2024
31bafb8
refactor: update email interface and email sending test
typeWolffo Aug 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion examples/common_nestjs_remix/apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
# GENERAL
CORS_ORIGIN="https://app.guidebook.localhost"
EMAIL_ADAPTER="mailhog"

# DATABASE
DATABASE_URL="postgres://postgres:guidebook@localhost:5432/guidebook"

# JWT
JWT_SECRET=
JWT_REFRESH_SECRET=
CORS_ORIGIN=
JWT_EXPIRATION_TIME=

# MAILS
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=

# AWS
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
5 changes: 5 additions & 0 deletions examples/common_nestjs_remix/apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@repo/email-templates": "workspace:*",
"@aws-sdk/client-ses": "^3.616.0",
"@knaadh/nestjs-drizzle-postgres": "^1.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.3",
Expand All @@ -43,12 +45,14 @@
"drizzle-typebox": "^0.1.1",
"lodash": "^4.17.21",
"nestjs-typebox": "3.0.0-next.8",
"nodemailer": "^6.9.14",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"postgres": "^3.4.4",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"ts-pattern": "^5.2.0",
"uuid": "^10.0.0"
},
"devDependencies": {
Expand All @@ -63,6 +67,7 @@
"@types/jest": "^29.5.2",
"@types/lodash": "^4.17.6",
"@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.15",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0",
Expand Down
6 changes: 5 additions & 1 deletion examples/common_nestjs_remix/apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { JwtModule } from "@nestjs/jwt";
import jwtConfig from "./common/configuration/jwt";
import emailConfig from "./common/configuration/email";
import awsConfig from "./common/configuration/aws";
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./common/guards/jwt-auth-guard";
import { EmailModule } from "./common/emails/emails.module";

@Module({
imports: [
ConfigModule.forRoot({
load: [database, jwtConfig],
load: [database, jwtConfig, emailConfig, awsConfig],
isGlobal: true,
}),
DrizzlePostgresModule.registerAsync({
Expand Down Expand Up @@ -47,6 +50,7 @@ import { JwtAuthGuard } from "./common/guards/jwt-auth-guard";
}),
AuthModule,
UsersModule,
EmailModule,
],
controllers: [],
providers: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ import { createUserFactory } from "test/factory/user.factory";
import { omit } from "lodash";
import hashPassword from "src/common/helpers/hashPassword";
import { truncateAllTables } from "test/helpers/test-helpers";
import { EmailTestingAdapter } from "test/helpers/test-email.adapter";

const mockEmailTestingAdapter = new EmailTestingAdapter();

jest.mock("../../common/emails/emails.service", () => ({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest to create EmailTestingAdapter which would get all messages and save them to array/object and provides methods like getAllEmail/lastEmail etc. this way we can easily check in tests what emails were sent and assert them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have EmailTestingAdapter, you don't need to mock it, inject it in the test application as an adapter, then use DI to work with it.

EmailService: jest.fn().mockImplementation(() => ({
sendEmail: jest
.fn()
.mockImplementation((email) => mockEmailTestingAdapter.sendMail(email)),
})),
}));

describe("AuthService", () => {
let testContext: TestContext;
Expand All @@ -32,6 +43,8 @@ describe("AuthService", () => {

afterEach(async () => {
await truncateAllTables(db);
mockEmailTestingAdapter.resetEmailOverride();
mockEmailTestingAdapter.clearEmails();
});

describe("register", () => {
Expand Down Expand Up @@ -60,6 +73,29 @@ describe("AuthService", () => {
);
});

it("should send a welcome email after successful registration", async () => {
const user = userFactory.build();
const password = "password123";
const subject = "Hello there!";
const text = "General Kenobi";
const html = "<strong>You are a bold one</strong>";

mockEmailTestingAdapter.setEmailOverride({
subject,
text,
html,
});

await authService.register(user.email, password);
const lastEmail = mockEmailTestingAdapter.getLastEmail();

expect(lastEmail).toBeDefined();
expect(lastEmail?.to).toBe(user.email);
expect(lastEmail?.subject).toBe(subject);
expect(lastEmail?.text).toBe(text);
expect(lastEmail?.html).toBe(html);
});

it("should throw ConflictException if user already exists", async () => {
const email = "existing@example.com";
const user = await userFactory.create({ email });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { JwtStrategy } from "./strategy/jwt.strategy";
import { LocalStrategy } from "./strategy/local.strategy";
import { TokenService } from "./token.service";
import { UsersService } from "src/users/users.service";
import { EmailModule } from "src/common/emails/emails.module";

@Module({
imports: [PassportModule],
imports: [PassportModule, EmailModule],
controllers: [AuthController],
providers: [
AuthService,
Expand Down
13 changes: 13 additions & 0 deletions examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { DatabasePg, UUIDType } from "src/common";
import { credentials, users } from "../storage/schema";
import { UsersService } from "../users/users.service";
import hashPassword from "src/common/helpers/hashPassword";
import { EmailService } from "src/common/emails/emails.service";
import { WelcomeEmail } from "@repo/email-templates";

@Injectable()
export class AuthService {
Expand All @@ -20,6 +22,7 @@ export class AuthService {
private jwtService: JwtService,
private usersService: UsersService,
private configService: ConfigService,
private emailService: EmailService,
) {}

public async register(email: string, password: string) {
Expand All @@ -41,6 +44,16 @@ export class AuthService {
.insert(credentials)
.values({ userId: newUser.id, password: hashedPassword });

const emailTemplate = new WelcomeEmail({ email, name: email });

await this.emailService.sendEmail({
to: email,
subject: "Welcome to our platform",
text: emailTemplate.text,
html: emailTemplate.html,
from: "godfather@selleo.com",
});

return newUser;
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

const schema = Type.Object({
AWS_REGION: Type.String(),
AWS_ACCESS_KEY_ID: Type.String(),
AWS_SECRET_ACCESS_KEY: Type.String(),
});

type AWSConfigSchema = Static<typeof schema>;

export default registerAs("aws", (): AWSConfigSchema => {
const values = {
AWS_REGION: process.env.AWS_REGION,
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
};

return Value.Decode(schema, values);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

const schema = Type.Object({
SMTP_HOST: Type.String(),
SMTP_PORT: Type.Number(),
SMTP_USER: Type.String(),
SMTP_PASSWORD: Type.String(),
USE_MAILHOG: Type.Boolean(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still use this one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, in smtp adapter.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

referring to previous comment

in prod environments smtp auth is not always the case, sometimes it is just service account

then we just need to create a new adapter?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should require environments based on what we want to use, eg I am going with local one I don't need to setup aws.

Copy link
Member Author

@typeWolffo typeWolffo Aug 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it works like that - it checks what adapter we have specified in env EMAIL_ADAPTER and dynamically instantiate the adapter using moduleRef.create() in emails-adapter.factory. If the value in env for the specified adapter is missing - it returns an error

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I may not understand something 😅

EMAIL_ADAPTER: Type.Union([
Type.Literal("mailhog"),
Type.Literal("smtp"),
Type.Literal("ses"),
]),
});

export type EmailConfigSchema = Static<typeof schema>;

export default registerAs("email", (): EmailConfigSchema => {
const values = {
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT ?? "465", 10),
SMTP_USER: process.env.SMTP_USER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
USE_MAILHOG: process.env.USE_MAILHOG === "true",
EMAIL_ADAPTER: process.env.EMAIL_ADAPTER,
};

return Value.Decode(schema, values);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Email } from "../email.interface";

export abstract class EmailAdapter {
abstract sendMail(email: Email): Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Injectable } from "@nestjs/common";
import * as nodemailer from "nodemailer";
import { Email } from "../email.interface";
import { EmailAdapter } from "./email.adapter";

@Injectable()
export class LocalAdapter extends EmailAdapter {
private transporter: nodemailer.Transporter;

constructor() {
super();
this.transporter = nodemailer.createTransport({
host: "localhost",
port: 1025,
ignoreTLS: true,
});
}

async sendMail(email: Email): Promise<void> {
await this.transporter.sendMail(email);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as nodemailer from "nodemailer";
import { Email } from "../email.interface";
import { EmailAdapter } from "./email.adapter";

@Injectable()
export class NodemailerAdapter extends EmailAdapter {
private transporter: nodemailer.Transporter;

constructor(private configService: ConfigService) {
super();
this.transporter = nodemailer.createTransport(this.getNodemailerOptions());
}

async sendMail(email: Email): Promise<void> {
await this.transporter.sendMail(email);
}

private getNodemailerOptions() {
return {
host: this.configService.get<string>("email.SMTP_HOST"),
port: this.configService.get<number>("email.SMTP_PORT"),
secure: true,
auth: {
user: this.configService.get<string>("email.SMTP_USER"),
pass: this.configService.get<string>("email.SMTP_PASSWORD"),
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SES, SESClientConfig } from "@aws-sdk/client-ses";
import { EmailAdapter } from "./email.adapter";
import { Email } from "../email.interface";

@Injectable()
export class AWSSESAdapter extends EmailAdapter {
private ses: SES;

constructor(private configService: ConfigService) {
super();
const config: SESClientConfig = this.getAWSConfig();
this.ses = new SES(config);
}

private getAWSConfig(): SESClientConfig {
const region = this.configService.get<string>("aws.AWS_REGION");
const accessKeyId = this.configService.get<string>("aws.AWS_ACCESS_KEY_ID");
const secretAccessKey = this.configService.get<string>(
"aws.AWS_SECRET_ACCESS_KEY",
);

if (!region || !accessKeyId || !secretAccessKey) {
throw new Error("Missing AWS configuration");
}

return {
region,
credentials: {
accessKeyId,
secretAccessKey,
},
};
}

async sendMail(email: Email): Promise<void> {
const params = {
Source: email.from,
Destination: {
ToAddresses: [email.to],
},
Message: {
Subject: {
Data: email.subject,
},
Body: {
Text: {
Data: email.text,
},
Html: {
Data: email.html,
},
},
},
};

try {
await this.ses.sendEmail(params);
} catch (error) {
console.error("Error sending email via AWS SES:", error);
throw error;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface Email {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this interface it is possible to pas an Email without both text and html. Can we make it that we need at least one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Copy link
Member

@mikoscz mikoscz Aug 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I specify email with only text, the html property has string | undefined should be undefined same other way around. Could we go with something simpler?

type Email = {
  to: string;
  from: string;
  subject: string;
} & (
  | { html: string; text?: never }
  | { text: string; html?: never }
  | { text: string; html: string }
);

to: string;
from: string;
subject: string;
text?: string;
html?: string;
}
Loading