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 all 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,24 +14,29 @@ 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";
import { EmailAdapter } from "src/common/emails/adapters/email.adapter";

describe("AuthService", () => {
let testContext: TestContext;
let authService: AuthService;
let jwtService: JwtService;
let db: DatabasePg;
let userFactory: ReturnType<typeof createUserFactory>;
let emailAdapter: EmailTestingAdapter;

beforeAll(async () => {
testContext = await createUnitTest();
authService = testContext.module.get(AuthService);
jwtService = testContext.module.get(JwtService);
db = testContext.db;
userFactory = createUserFactory(db);
emailAdapter = testContext.module.get(EmailAdapter);
}, 30000);

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

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

it("should send a welcome email after successful registration", async () => {
const user = userFactory.build();
const password = "password123";

const allEmails = emailAdapter.getAllEmails();

expect(allEmails).toHaveLength(0);
await authService.register(user.email, password);
expect(allEmails).toHaveLength(1);
});

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,23 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { configValidator } from "src/utils/configValidator";

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

const validateAwsConfig = configValidator(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 validateAwsConfig(values);
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { configValidator } from "src/utils/configValidator";

const schema = Type.Object({
url: Type.String(),
});

type DatabaseConfig = Static<typeof schema>;

const validateDatabaseConfig = configValidator(schema);

export default registerAs("database", (): DatabaseConfig => {
const values = {
url: process.env.DATABASE_URL,
};

return Value.Decode(schema, values);
return validateDatabaseConfig(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 { configValidator } from "src/utils/configValidator";

const schema = Type.Object({
SMTP_HOST: Type.String(),
SMTP_PORT: Type.Number(),
SMTP_USER: Type.String(),
SMTP_PASSWORD: Type.String(),
EMAIL_ADAPTER: Type.Union([
Type.Literal("mailhog"),
Type.Literal("smtp"),
Type.Literal("ses"),
]),
});

export type EmailConfigSchema = Static<typeof schema>;

const valdateEmailConfig = configValidator(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,
EMAIL_ADAPTER: process.env.EMAIL_ADAPTER,
};

return valdateEmailConfig(values);
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { registerAs } from "@nestjs/config";
import { Static, Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { configValidator } from "src/utils/configValidator";

const schema = Type.Object({
secret: Type.String(),
Expand All @@ -10,12 +10,14 @@ const schema = Type.Object({

type JWTConfig = Static<typeof schema>;

const valdateJWTConfig = configValidator(schema);

export default registerAs("jwt", (): JWTConfig => {
const values = {
secret: process.env.JWT_SECRET,
refreshSecret: process.env.JWT_REFRESH_SECRET,
expirationTime: process.env.JWT_EXPIRATION_TIME,
};

return Value.Decode(schema, values);
return valdateJWTConfig(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,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;
}
}
}
Loading