diff --git a/bun.lock b/bun.lock index 3b9211c..36b828f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "name": "void.backend", "dependencies": { "@atums/echo": "latest", + "@types/nodemailer": "^6.4.17", "cassandra-driver": "latest", "fast-jwt": "latest", "nodemailer": "^7.0.3", @@ -46,6 +47,8 @@ "@types/node": ["@types/node@18.19.111", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw=="], + "@types/nodemailer": ["@types/nodemailer@6.4.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], diff --git a/package.json b/package.json index 4d88736..466601d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "start": "bun run src/index.ts", - "command": "bun run src/command.ts", + "command": "bun run src/index.ts", "dev": "bun run --hot src/index.ts --dev", "lint": "bunx biome check", "lint:fix": "bunx biome check --fix", @@ -16,6 +16,7 @@ }, "dependencies": { "@atums/echo": "latest", + "@types/nodemailer": "^6.4.17", "cassandra-driver": "latest", "fast-jwt": "latest", "nodemailer": "^7.0.3", diff --git a/src/environment/constants/database/index.ts b/src/environment/constants/database.ts similarity index 100% rename from src/environment/constants/database/index.ts rename to src/environment/constants/database.ts diff --git a/src/environment/constants/index.ts b/src/environment/constants/index.ts index 49ee918..dab8414 100644 --- a/src/environment/constants/index.ts +++ b/src/environment/constants/index.ts @@ -1,3 +1,4 @@ export * from "./server"; export * from "./validation"; export * from "./database"; +export * from "./mailer"; diff --git a/src/environment/constants/mailer.ts b/src/environment/constants/mailer.ts new file mode 100644 index 0000000..c8c40ad --- /dev/null +++ b/src/environment/constants/mailer.ts @@ -0,0 +1,8 @@ +import { resolve } from "node:path"; + +export const templatesPath = resolve( + "src", + "environment", + "mailer", + "templates", +); diff --git a/src/environment/constants/validation.ts b/src/environment/constants/validation.ts index 4cdb432..8232cc3 100644 --- a/src/environment/constants/validation.ts +++ b/src/environment/constants/validation.ts @@ -14,7 +14,7 @@ const forbiddenDisplayNamePatterns = [ /[\r\n\t]/, /\s{3,}/, /^\s|\s$/, - /#everyone|#here/i, + /@everyone|@here/i, /\p{Cf}/u, /\p{Cc}/u, ]; @@ -25,7 +25,7 @@ const passwordRestrictions: genericValidation = { }; const emailRestrictions: { regex: RegExp } = { - regex: /^[^\s#]+#[^\s#]+\.[^\s#]+$/, + regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, }; export { diff --git a/src/environment/mailer.ts b/src/environment/mailer/index.ts similarity index 100% rename from src/environment/mailer.ts rename to src/environment/mailer/index.ts diff --git a/src/environment/mailer/templates/register.html b/src/environment/mailer/templates/register.html new file mode 100644 index 0000000..5573145 --- /dev/null +++ b/src/environment/mailer/templates/register.html @@ -0,0 +1,32 @@ + + + + + + + {{subject}} + + + +

Hi {{displayName}},

+

Thank you for registering with {{companyName}}. Your account has been successfully created.

+ +

Account Details:

+

+ User ID: {{id}}
+ Verification Status: {{isVerified}} +

+ +

To get started, please verify your email address by clicking the link below:

+

Verify Email Address

+ +

If you didn't create this account, please ignore this email.

+
+ +

+ Best regards,
+ The {{companyName}} team +

+ + + diff --git a/src/index.ts b/src/index.ts index b137066..26240fa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { handleCommands } from "#commands"; import { verifyRequiredVariables } from "#environment/config"; import { migrationRunner } from "#lib/database"; +import { mailerService } from "#lib/mailer"; import { serverHandler } from "#server"; const noFileLog = new Echo({ @@ -16,6 +17,8 @@ async function main(): Promise { verifyRequiredVariables(); await migrationRunner.initialize(); + await mailerService.verifyConnection(); + serverHandler.initialize(); } diff --git a/src/lib/mailer/index.ts b/src/lib/mailer/index.ts new file mode 100644 index 0000000..2fa26be --- /dev/null +++ b/src/lib/mailer/index.ts @@ -0,0 +1,220 @@ +import { readdir } from "node:fs/promises"; +import { echo } from "@atums/echo"; +import nodemailer from "nodemailer"; +import { templatesPath } from "#environment/constants"; +import { mailerConfig } from "#environment/mailer"; +import { noFileLog } from "#index"; + +import type { + EmailResult, + SendMailOptions, + TemplateVariables, +} from "#types/lib"; + +class MailerService { + private transporter: nodemailer.Transporter; + + constructor() { + this.transporter = nodemailer.createTransport({ + host: mailerConfig.address, + port: mailerConfig.port, + secure: mailerConfig.secure, + auth: { + user: mailerConfig.username, + pass: mailerConfig.password, + }, + pool: mailerConfig.pool, + maxConnections: mailerConfig.maxConnections, + maxMessages: mailerConfig.maxMessages, + connectionTimeout: mailerConfig.connectionTimeout, + socketTimeout: mailerConfig.socketTimeout, + requireTLS: !mailerConfig.secure, + tls: { + rejectUnauthorized: true, + }, + } as nodemailer.TransportOptions); + } + + async verifyConnection(): Promise { + try { + await this.transporter.verify(); + noFileLog.info("SMTP connection verified successfully"); + return true; + } catch (error) { + echo.error({ message: "SMTP connection verification failed:", error }); + return false; + } + } + + async close(): Promise { + this.transporter.close(); + } + + private async listTemplates(): Promise { + try { + const files = await readdir(templatesPath, { recursive: true }); + return files + .filter((file) => typeof file === "string" && file.endsWith(".html")) + .map((file) => (file as string).replace(/\.html$/, "")); + } catch (error) { + echo.error({ message: "Failed to list templates:", error }); + return []; + } + } + + private async renderTemplate( + templateName: string, + variables: TemplateVariables, + ): Promise { + const templates = await this.listTemplates(); + if (!templates.includes(templateName)) { + throw new Error(`Template "${templateName}" not found`); + } + + const templatePath = `${templatesPath}/${templateName}.html`; + let templateContent: string; + + try { + const file = Bun.file(templatePath); + templateContent = await file.text(); + } catch (error) { + echo.error({ + message: `Failed to load template "${templateName}":`, + error, + }); + throw error; + } + + const rewriter = new HTMLRewriter().on("*", { + text(text) { + if (text.text) { + let modifiedText = text.text; + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`{{${key}}}`, "g"); + modifiedText = modifiedText.replace(regex, String(value)); + } + if (modifiedText !== text.text) { + text.replace(modifiedText); + } + } + }, + }); + + const response = new Response(templateContent, { + headers: { "Content-Type": "text/html" }, + }); + + const transformedResponse = rewriter.transform(response); + return await transformedResponse.text(); + } + + async sendEmail(options: SendMailOptions): Promise { + try { + let htmlContent: string; + let textContent: string | undefined; + + if (options.template && options.templateVariables) { + htmlContent = await this.renderTemplate( + options.template, + options.templateVariables, + ); + + if (!options.text) { + textContent = this.htmlToText(htmlContent); + } else { + textContent = options.text; + } + } else if (options.html) { + htmlContent = options.html; + textContent = options.text; + } else { + throw new Error("Either template or html content must be provided"); + } + + const mailOptions: nodemailer.SendMailOptions = { + from: options.from || mailerConfig.from, + to: Array.isArray(options.to) ? options.to.join(", ") : options.to, + cc: options.cc + ? Array.isArray(options.cc) + ? options.cc.join(", ") + : options.cc + : undefined, + bcc: options.bcc + ? Array.isArray(options.bcc) + ? options.bcc.join(", ") + : options.bcc + : undefined, + subject: options.subject, + text: textContent, + html: htmlContent, + attachments: options.attachments, + replyTo: options.replyTo, + priority: options.priority, + headers: options.headers, + }; + + const info = await this.transporter.sendMail(mailOptions); + + noFileLog.info({ + message: `Email sent successfully to ${options.to}`, + messageId: info.messageId, + subject: options.subject, + template: options.template, + }); + + return { + success: true, + messageId: info.messageId, + response: info.response, + }; + } catch (error) { + echo.error({ + message: "Failed to send email:", + error, + to: options.to, + subject: options.subject, + template: options.template, + }); + + return { + success: false, + error: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + } + + async sendTemplateEmail( + to: string | string[], + subject: string, + templateName: string, + variables: TemplateVariables, + options?: Partial, + ): Promise { + return this.sendEmail({ + to, + subject, + template: templateName, + templateVariables: variables, + ...options, + }); + } + + private htmlToText(html: string): string { + return html + .replace(/]*>.*?<\/style>/gis, "") // remove style tags + .replace(/]*>.*?<\/script>/gis, "") // remove script tags + .replace(/<[^>]*>/g, "") // remove HTML tags + .replace(/ /g, " ") // replace non-breaking spaces + .replace(/&/g, "&") // replace ampersands + .replace(/</g, "<") // replace less than + .replace(/>/g, ">") // replace greater than + .replace(/"/g, '"') // replace quotes + .replace(/'/g, "'") // replace apostrophes + .replace(/\s+/g, " ") // replace multiple whitespace with single space + .trim(); + } +} + +const mailerService = new MailerService(); +export { MailerService, mailerService }; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index ed3b52d..389fb9f 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,2 +1,2 @@ export * from "./idGenerator"; -export * from "./jwt"; +export * from "./time"; diff --git a/src/lib/utils/jwt.ts b/src/lib/utils/time.ts similarity index 100% rename from src/lib/utils/jwt.ts rename to src/lib/utils/time.ts diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts index 8c7d549..cebc732 100644 --- a/src/routes/user/register.ts +++ b/src/routes/user/register.ts @@ -1,4 +1,6 @@ +import { environment } from "#environment/config"; import { cassandra } from "#lib/database"; +import { mailerService } from "#lib/mailer"; import { pika } from "#lib/utils"; import { isValidDisplayName, @@ -7,6 +9,7 @@ import { isValidUsername, } from "#lib/validation"; +import { echo } from "@atums/echo"; import type { ExtendedRequest, RegisterRequest, @@ -155,6 +158,31 @@ async function handler( user: responseUser, }; + try { + const emailVariables = { + subject: "Welcome to void", + companyName: "void", + id: responseUser.id, + displayName: responseUser.displayName || responseUser.username, + isVerified: "Pending verification", + verificationUrl: `${environment.fqdn}/verify?token=generated_token`, // TODO: Actually generate a token + }; + + mailerService.sendTemplateEmail( + responseUser.email, + "Welcome to void", + "register", + emailVariables, + ); + } catch (error) { + echo.error({ + message: "Failed to send registration email", + error, + to: responseUser.email, + template: "register", + }); + } + return Response.json(response, { status: 201 }); } catch { const response: RegisterResponse = { diff --git a/types/config/mailer.ts b/types/config/mailer.ts index cd02ab6..5025d3b 100644 --- a/types/config/mailer.ts +++ b/types/config/mailer.ts @@ -13,30 +13,4 @@ type MailerConfig = { replyTo: string; }; -type EmailOptions = { - to: string | string[]; - subject: string; - text?: string; - html?: string; - from?: string; - replyTo?: string; - cc?: string | string[]; - bcc?: string | string[]; - attachments?: EmailAttachment[]; -}; - -type EmailAttachment = { - filename: string; - content?: Buffer | string; - path?: string; - contentType?: string; - cid?: string; // content-ID for embedded images -}; - -type EmailResult = { - success: boolean; - messageId?: string; - error?: string; -}; - -export type { MailerConfig, EmailOptions, EmailAttachment, EmailResult }; +export type { MailerConfig }; diff --git a/types/lib/index.ts b/types/lib/index.ts index 99455b8..e882501 100644 --- a/types/lib/index.ts +++ b/types/lib/index.ts @@ -1 +1,2 @@ export * from "./validation"; +export * from "./mailer"; diff --git a/types/lib/mailer.ts b/types/lib/mailer.ts new file mode 100644 index 0000000..c0d2708 --- /dev/null +++ b/types/lib/mailer.ts @@ -0,0 +1,45 @@ +import type { Attachment } from "nodemailer/lib/mailer"; + +interface TemplateVariables { + [key: string]: string | number | boolean; +} + +interface SendMailOptions { + to: string | string[]; + subject: string; + cc?: string | string[]; + bcc?: string | string[]; + from?: string; + replyTo?: string; + template?: string; + templateVariables?: TemplateVariables; + html?: string; + text?: string; + attachments?: Attachment[]; + priority?: "high" | "normal" | "low"; + headers?: Record; +} + +type MailerConfig = { + address: string; + port: number; + from: string; + username: string; + password: string; + secure: boolean; + pool: boolean; + connectionTimeout: number; + socketTimeout: number; + maxConnections: number; + maxMessages: number; + replyTo: string; +}; + +type EmailResult = { + success: boolean; + messageId?: string; + response?: string; + error?: string; +}; + +export type { TemplateVariables, SendMailOptions, MailerConfig, EmailResult }; diff --git a/types/server/requests/user/register.ts b/types/server/requests/user/register.ts index 21ff477..21358a3 100644 --- a/types/server/requests/user/register.ts +++ b/types/server/requests/user/register.ts @@ -1,4 +1,5 @@ -import type { BaseResponse, UserResponse } from "./base"; +import type { BaseResponse } from "../base"; +import type { UserResponse } from "./base"; interface RegisterRequest { username: string;