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",
|
"name": "void.backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
"nodemailer": "^7.0.3",
|
"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/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=="],
|
"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=="],
|
"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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"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",
|
"dev": "bun run --hot src/index.ts --dev",
|
||||||
"lint": "bunx biome check",
|
"lint": "bunx biome check",
|
||||||
"lint:fix": "bunx biome check --fix",
|
"lint:fix": "bunx biome check --fix",
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
export * from "./server";
|
export * from "./server";
|
||||||
export * from "./validation";
|
export * from "./validation";
|
||||||
export * from "./database";
|
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]/,
|
/[\r\n\t]/,
|
||||||
/\s{3,}/,
|
/\s{3,}/,
|
||||||
/^\s|\s$/,
|
/^\s|\s$/,
|
||||||
/#everyone|#here/i,
|
/@everyone|@here/i,
|
||||||
/\p{Cf}/u,
|
/\p{Cf}/u,
|
||||||
/\p{Cc}/u,
|
/\p{Cc}/u,
|
||||||
];
|
];
|
||||||
|
@ -25,7 +25,7 @@ const passwordRestrictions: genericValidation = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailRestrictions: { regex: RegExp } = {
|
const emailRestrictions: { regex: RegExp } = {
|
||||||
regex: /^[^\s#]+#[^\s#]+\.[^\s#]+$/,
|
regex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
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 { verifyRequiredVariables } from "#environment/config";
|
||||||
import { migrationRunner } from "#lib/database";
|
import { migrationRunner } from "#lib/database";
|
||||||
|
import { mailerService } from "#lib/mailer";
|
||||||
import { serverHandler } from "#server";
|
import { serverHandler } from "#server";
|
||||||
|
|
||||||
const noFileLog = new Echo({
|
const noFileLog = new Echo({
|
||||||
|
@ -16,6 +17,8 @@ async function main(): Promise<void> {
|
||||||
verifyRequiredVariables();
|
verifyRequiredVariables();
|
||||||
|
|
||||||
await migrationRunner.initialize();
|
await migrationRunner.initialize();
|
||||||
|
await mailerService.verifyConnection();
|
||||||
|
|
||||||
serverHandler.initialize();
|
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 "./idGenerator";
|
||||||
export * from "./jwt";
|
export * from "./time";
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { environment } from "#environment/config";
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
|
import { mailerService } from "#lib/mailer";
|
||||||
import { pika } from "#lib/utils";
|
import { pika } from "#lib/utils";
|
||||||
import {
|
import {
|
||||||
isValidDisplayName,
|
isValidDisplayName,
|
||||||
|
@ -7,6 +9,7 @@ import {
|
||||||
isValidUsername,
|
isValidUsername,
|
||||||
} from "#lib/validation";
|
} from "#lib/validation";
|
||||||
|
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
import type {
|
import type {
|
||||||
ExtendedRequest,
|
ExtendedRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
@ -155,6 +158,31 @@ async function handler(
|
||||||
user: responseUser,
|
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 });
|
return Response.json(response, { status: 201 });
|
||||||
} catch {
|
} catch {
|
||||||
const response: RegisterResponse = {
|
const response: RegisterResponse = {
|
||||||
|
|
|
@ -13,30 +13,4 @@ type MailerConfig = {
|
||||||
replyTo: string;
|
replyTo: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EmailOptions = {
|
export type { MailerConfig };
|
||||||
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 };
|
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
export * from "./validation";
|
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 {
|
interface RegisterRequest {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue