Skip to content

Commit

Permalink
Merge pull request #10 from Selleo/jw/emails
Browse files Browse the repository at this point in the history
feat: emails
  • Loading branch information
typeWolffo authored Aug 1, 2024
2 parents 0f02e10 + 31bafb8 commit 2636bc1
Show file tree
Hide file tree
Showing 34 changed files with 3,917 additions and 415 deletions.
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 = "[email protected]";
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: "[email protected]",
});

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

0 comments on commit 2636bc1

Please sign in to comment.