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(/