actually start on mailing, fix email regex
Some checks failed
Code quality checks / biome (push) Failing after 22s

This commit is contained in:
creations 2025-06-10 16:34:15 -04:00
parent 00a7417936
commit 86be889ede
Signed by: creations
GPG key ID: 8F553AA4320FC711
17 changed files with 349 additions and 32 deletions

View file

@ -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=="],

View file

@ -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",

View file

@ -1,3 +1,4 @@
export * from "./server";
export * from "./validation";
export * from "./database";
export * from "./mailer";

View file

@ -0,0 +1,8 @@
import { resolve } from "node:path";
export const templatesPath = resolve(
"src",
"environment",
"mailer",
"templates",
);

View file

@ -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 {

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{subject}}</title>
</head>
<body>
<p>Hi {{displayName}},</p>
<p>Thank you for registering with {{companyName}}. Your account has been successfully created.</p>
<h2>Account Details:</h2>
<p>
<strong>User ID:</strong> {{id}}<br>
<strong>Verification Status:</strong> {{isVerified}}
</p>
<p>To get started, please verify your email address by clicking the link below:</p>
<p><a href="{{verificationUrl}}">Verify Email Address</a></p>
<p>If you didn't create this account, please ignore this email.</p>
<hr>
<p>
Best regards,<br>
The {{companyName}} team
</p>
</body>
</html>

View file

@ -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<void> {
verifyRequiredVariables();
await migrationRunner.initialize();
await mailerService.verifyConnection();
serverHandler.initialize();
}

220
src/lib/mailer/index.ts Normal file
View file

@ -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<boolean> {
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<void> {
this.transporter.close();
}
private async listTemplates(): Promise<string[]> {
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<string> {
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<EmailResult> {
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<SendMailOptions>,
): Promise<EmailResult> {
return this.sendEmail({
to,
subject,
template: templateName,
templateVariables: variables,
...options,
});
}
private htmlToText(html: string): string {
return html
.replace(/<style[^>]*>.*?<\/style>/gis, "") // remove style tags
.replace(/<script[^>]*>.*?<\/script>/gis, "") // remove script tags
.replace(/<[^>]*>/g, "") // remove HTML tags
.replace(/&nbsp;/g, " ") // replace non-breaking spaces
.replace(/&amp;/g, "&") // replace ampersands
.replace(/&lt;/g, "<") // replace less than
.replace(/&gt;/g, ">") // replace greater than
.replace(/&quot;/g, '"') // replace quotes
.replace(/&#39;/g, "'") // replace apostrophes
.replace(/\s+/g, " ") // replace multiple whitespace with single space
.trim();
}
}
const mailerService = new MailerService();
export { MailerService, mailerService };

View file

@ -1,2 +1,2 @@
export * from "./idGenerator";
export * from "./jwt";
export * from "./time";

View file

@ -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 = {

View file

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

View file

@ -1 +1,2 @@
export * from "./validation";
export * from "./mailer";

45
types/lib/mailer.ts Normal file
View file

@ -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<string, string>;
}
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 };

View file

@ -1,4 +1,5 @@
import type { BaseResponse, UserResponse } from "./base";
import type { BaseResponse } from "../base";
import type { UserResponse } from "./base";
interface RegisterRequest {
username: string;