actually start on mailing, fix email regex
Some checks failed
Code quality checks / biome (push) Failing after 22s
Some checks failed
Code quality checks / biome (push) Failing after 22s
This commit is contained in:
parent
00a7417936
commit
86be889ede
17 changed files with 349 additions and 32 deletions
3
bun.lock
3
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=="],
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./server";
|
||||
export * from "./validation";
|
||||
export * from "./database";
|
||||
export * from "./mailer";
|
||||
|
|
8
src/environment/constants/mailer.ts
Normal file
8
src/environment/constants/mailer.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { resolve } from "node:path";
|
||||
|
||||
export const templatesPath = resolve(
|
||||
"src",
|
||||
"environment",
|
||||
"mailer",
|
||||
"templates",
|
||||
);
|
|
@ -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 {
|
||||
|
|
32
src/environment/mailer/templates/register.html
Normal file
32
src/environment/mailer/templates/register.html
Normal 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>
|
|
@ -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
220
src/lib/mailer/index.ts
Normal 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(/ /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 };
|
|
@ -1,2 +1,2 @@
|
|||
export * from "./idGenerator";
|
||||
export * from "./jwt";
|
||||
export * from "./time";
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * from "./validation";
|
||||
export * from "./mailer";
|
||||
|
|
45
types/lib/mailer.ts
Normal file
45
types/lib/mailer.ts
Normal 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 };
|
|
@ -1,4 +1,5 @@
|
|||
import type { BaseResponse, UserResponse } from "./base";
|
||||
import type { BaseResponse } from "../base";
|
||||
import type { UserResponse } from "./base";
|
||||
|
||||
interface RegisterRequest {
|
||||
username: string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue