upd: split files for easier contributing, cleaned up code, add bump ci
All checks were successful
Bump Dependencies / update-dependencies (push) Successful in 19s
Build and Push Nightly CI Image / build_and_push (push) Successful in 1m43s
Build and Push Docker Image / build_and_push (push) Successful in 4s
All checks were successful
Bump Dependencies / update-dependencies (push) Successful in 19s
Build and Push Nightly CI Image / build_and_push (push) Successful in 1m43s
Build and Push Docker Image / build_and_push (push) Successful in 4s
This commit is contained in:
parent
1ecd4f4629
commit
fdf37ac9b1
@ -1,2 +1,3 @@
|
||||
MIGRATE_TXT="migrate.txt"
|
||||
DB_FILE_NAME="db.sqlite3"
|
||||
MAILCONNECT_ROOT_DIR="/app"
|
40
.gitea/workflows/bump.yml
Normal file
40
.gitea/workflows/bump.yml
Normal file
@ -0,0 +1,40 @@
|
||||
name: Bump Dependencies
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
- cron: "0 12 * * *"
|
||||
|
||||
jobs:
|
||||
update-dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install Dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Update Dependencies
|
||||
run: bun update
|
||||
|
||||
- name: Commit and Push Changes
|
||||
env:
|
||||
USERNAME_GITEA: ${{ secrets.USERNAME_GITEA }}
|
||||
TOKEN_GITEA: ${{ secrets.TOKEN_GITEA }}
|
||||
run: |
|
||||
git config --global user.name "LibreCloud Actions Bot"
|
||||
git config --global user.email "git@pontusmail.org"
|
||||
git remote set-url origin https://${USERNAME_GITEA}:${TOKEN_GITEA}@git.pontusmail.org/librecloud/mail-connect.git
|
||||
git add .
|
||||
git commit -m "chore: bump dependencies" || exit 0
|
||||
git push origin main
|
@ -15,7 +15,7 @@ We provide an extendable API which interacts with the `setup` utility via a Dock
|
||||
|
||||
## What this API is NOT
|
||||
|
||||
This API is insecure by nature, however not completely. It's meant to be an internal API, and used in frontends which have their own protection systems in place. Think about it... would you like me to direct your mailserver security? I sure hope not...
|
||||
This API is insecure by nature, however not completely. `mail-connect` is intended to be an API which is used internally _only_. The systems connected to this API should have proper protections against abuse. Think about it... would you like me to direct your mailserver security? I sure hope not...
|
||||
|
||||
As such, users who have access to this API are able to create unlimited accounts, and modify anyone's email address. Thus, your code should be the only user of this API. Once again, **do not make this API public**.
|
||||
|
||||
@ -45,9 +45,11 @@ All features marked with an **E** are extended features, and are not a part of t
|
||||
```bash
|
||||
touch migrate.txt # put emails (one per line) which already exist on the server which users can claim
|
||||
cp .env.example .env # you don't need to change anything here
|
||||
vim ratelimit.json # optional, customize to your liking
|
||||
vim ratelimit.json # optional, customize to your liking...
|
||||
```
|
||||
|
||||
**Note:** If you are running mail-connect outside a Docker container (or changing the binds), please change the `MAILCONNECT_ROOT_DIR` to match your environment.
|
||||
|
||||
4. **Build and run the container**
|
||||
|
||||
```bash
|
||||
|
@ -1,6 +1,6 @@
|
||||
services:
|
||||
mail-connect:
|
||||
build: .
|
||||
image: "git.pontusmail.org/librecloud/web:latest"
|
||||
container_name: mail-connect
|
||||
env_file: ".env"
|
||||
ports:
|
||||
|
@ -2,6 +2,10 @@
|
||||
"name": "mail-connect",
|
||||
"module": "src/server.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun src/server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/dockerode": "^3.3.35",
|
||||
@ -15,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/figlet": "^1.7.0",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"child_process": "^1.0.2",
|
||||
"dockerode": "^4.0.4",
|
||||
@ -22,6 +27,7 @@
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"figlet": "^1.8.0",
|
||||
"password-validator": "^5.3.0",
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
|
126
src/actions/accounts/addAccount.ts
Normal file
126
src/actions/accounts/addAccount.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { Request, Response } from "express";
|
||||
import { containerExec } from "../../utils/docker";
|
||||
import { validateEmail, validatePassword } from "../../utils/validators";
|
||||
import { accounts } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "fs/promises";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { updateAccountsCache } from "../../utils/updateAccountsCache";
|
||||
|
||||
const db = drizzle(process.env.DB_FILE_NAME!);
|
||||
|
||||
export const addAccount = async (req: Request, res: Response): Promise<void> => {
|
||||
const { email, password, migrate } = req.body;
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | Invalid email format");
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
let finalPassword = password;
|
||||
|
||||
if (migrate) {
|
||||
console.log("[*] Task started\nTASK| addAccount (subtask: migrate)\nACC |", email);
|
||||
try {
|
||||
const data = await fs.readFile("/app/migrate.txt", "utf8");
|
||||
const line = data.split("\n").find(l => l.trim() === email);
|
||||
if (!line) {
|
||||
console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Account not found in migrate.txt\nACC |", email);
|
||||
res.status(500).json({ error: "Backend error" });
|
||||
return;
|
||||
} else {
|
||||
const newData = data.replace(line, "");
|
||||
await fs.writeFile("/app/migrate.txt", newData);
|
||||
const output = await containerExec(["setup", "email", "update", email, finalPassword]);
|
||||
if (/ERROR/i.test(output)) {
|
||||
console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Password update failed\nACC |", email);
|
||||
res.status(500).json({ error: "Migration failed" });
|
||||
return;
|
||||
} else {
|
||||
await updateAccountsCache();
|
||||
const addedAccount = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (addedAccount.length > 0) {
|
||||
console.log("[*] Task completed\nTASK| addAccount (subtask: migrate)\nACC |", email);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} else {
|
||||
console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | Account not found in database\nACC |", email);
|
||||
res.status(500).json({ error: "Migration failed" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// the [1] makes it easy to identify where errors with the same message are coming from
|
||||
console.log("[!] Error\nTASK| addAccount (subtask: migrate)\nERR | [1] Unspecified error\nACC |", email);
|
||||
res.status(500).json({ error: "Backend error" });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log("[*] Task started\nTASK| addAccount\nACC |", email);
|
||||
try {
|
||||
const account = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (account.length > 0) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | Account already exists\nACC |", email);
|
||||
res.status(400).json({ error: "Account already exists" });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | [2] Unspecified error\nLOGS|", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalPassword || !validatePassword(finalPassword)) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | Invalid or weak password\nACC |", email);
|
||||
res.status(400).json({ error: "Invalid or weak password" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await containerExec(["setup", "email", "add", email, finalPassword]);
|
||||
if (/ERROR/i.test(output)) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | Error during account creation\nACC |", email);
|
||||
res.status(500).json({ error: "Error during account creation" });
|
||||
return;
|
||||
}
|
||||
let accountFound = false;
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
await updateAccountsCache();
|
||||
const addedAccount = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (addedAccount.length > 0) {
|
||||
accountFound = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
if (accountFound) {
|
||||
console.log("[*] Task completed\nTASK| addAccount\nACC |", email);
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | Timed out waiting for account creation\nACC |", email);
|
||||
res.status(500).json({
|
||||
error: "Check timed out waiting for account creation",
|
||||
failureReason: "Timed out waiting for account creation",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[!] Error\nTASK| addAccount\nERR | [3] Unspecified error\nLOGS|", error);
|
||||
res.status(500).json({ error: "Backend error" });
|
||||
}
|
||||
};
|
37
src/actions/accounts/getUserAccount.ts
Normal file
37
src/actions/accounts/getUserAccount.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Request, Response } from "express";
|
||||
import { accounts } from "../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { validateEmail } from "../../utils/validators";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
const db = drizzle(process.env.DB_FILE_NAME!);
|
||||
|
||||
export const getUserAccount = async (req: Request, res: Response): Promise<void> => {
|
||||
const { email } = req.body;
|
||||
console.log("[*] Task started\nTASK| getUserAccount\nACC |", email);
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
console.log("[!] Error\nTASK| getUserAccount\nERR | Invalid email format\nACC |", email);
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (account.length > 0) {
|
||||
console.log("[*] Task completed\nTASK| getUserAccount\nACC |", email);
|
||||
res.json({ account: account[0] });
|
||||
} else {
|
||||
console.log("[!] Error\nTASK| getUserAccount\nERR | Account not found\nACC |", email);
|
||||
res.status(404).json({ error: "Account not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[!] Error\nTASK| getUserAccount\nERR | Unspecified error\nACC |", email);
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
};
|
28
src/actions/accounts/listAccounts.ts
Normal file
28
src/actions/accounts/listAccounts.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Request, Response } from "express";
|
||||
import { cacheInfo, accounts } from "../../db/schema";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { updateAccountsCache } from "../../utils/updateAccountsCache";
|
||||
|
||||
const db = drizzle(process.env.DB_FILE_NAME!);
|
||||
|
||||
export const listAccounts = async (_req: Request, res: Response): Promise<void> => {
|
||||
console.log("[*] Task started\nTASK| listAccounts");
|
||||
|
||||
try {
|
||||
const cacheData = await db.select().from(cacheInfo).limit(1);
|
||||
const lastUpdated = cacheData[0]?.lastUpdated || 0;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime - lastUpdated > 30 * 60 * 1000) {
|
||||
// 30 minutes
|
||||
await updateAccountsCache();
|
||||
}
|
||||
|
||||
const accountsList = await db.select().from(accounts);
|
||||
console.log("[*] Task completed\nTASK| listAccounts");
|
||||
res.json({ accounts: accountsList });
|
||||
} catch (err) {
|
||||
console.log("[!] Error\nTASK| listAccounts\nERR | Unspecified error\nLOG |", (err as Error).message);
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
};
|
28
src/actions/accounts/updatePassword.ts
Normal file
28
src/actions/accounts/updatePassword.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Request, Response } from "express";
|
||||
import { containerExec } from "../../utils/docker";
|
||||
import { validateEmail } from "../../utils/validators";
|
||||
|
||||
export const updatePassword = async (req: Request, res: Response): Promise<void> => {
|
||||
const { email, password } = req.body;
|
||||
console.log("[*] Task started\nTASK| updatePassword\nACC |", email);
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
console.log("[!] Error\nTASK| updatePassword\nERR | Invalid email format\nACC |", email);
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await containerExec(["setup", "email", "update", email, password]);
|
||||
if (/ERROR/i.test(output)) {
|
||||
console.log("[!] Error\nTASK| updatePassword\nERR | Password update failed\nACC |", email);
|
||||
res.status(500).json({ error: "Error during reset" });
|
||||
} else {
|
||||
console.log("[*] Task completed\nTASK| updatePassword\nACC |", email);
|
||||
res.json({ success: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[!] Error\nTASK| updatePassword\nERR | Unspecified error\nACC |", email);
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
};
|
375
src/server.ts
375
src/server.ts
@ -1,352 +1,45 @@
|
||||
import express from "express"
|
||||
import fs from "fs/promises"
|
||||
import rateLimit from "express-rate-limit"
|
||||
import Docker from "dockerode"
|
||||
import validator from "validator"
|
||||
import PasswordValidator from "password-validator"
|
||||
import { accounts, cacheInfo } from "./db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { Request, Response } from "express"
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import express from "express";
|
||||
import figlet from "figlet";
|
||||
import { createLimiter, loadRateLimitConfig } from "./utils/rateLimit";
|
||||
import { listAccounts } from "./actions/accounts/listAccounts";
|
||||
import { getUserAccount } from "./actions/accounts/getUserAccount";
|
||||
import { updatePassword } from "./actions/accounts/updatePassword";
|
||||
import { addAccount } from "./actions/accounts/addAccount";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
interface RateLimitOptions {
|
||||
windowMs: number
|
||||
limit: number
|
||||
message?: string
|
||||
windowMs: number;
|
||||
limit: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RateLimitConfig {
|
||||
[endpoint: string]: RateLimitOptions
|
||||
}
|
||||
|
||||
interface Account {
|
||||
email: string
|
||||
used: string
|
||||
capacity: string
|
||||
percentage: string
|
||||
}
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" })
|
||||
const app = express()
|
||||
const db = drizzle(process.env.DB_FILE_NAME!);
|
||||
const container = docker.getContainer("mailserver");
|
||||
app.use(express.json())
|
||||
|
||||
let rateLimitConfig: RateLimitConfig = {}
|
||||
;(async () => {
|
||||
try {
|
||||
const data = await fs.readFile("/app/ratelimit.json", "utf8")
|
||||
rateLimitConfig = JSON.parse(data)
|
||||
} catch (err) {
|
||||
console.error("Error loading rate limit config:", err)
|
||||
}
|
||||
})()
|
||||
|
||||
const createLimiter = (options: RateLimitOptions) =>
|
||||
rateLimit({
|
||||
windowMs: options.windowMs,
|
||||
limit: options.limit,
|
||||
message: options.message || "Too many requests, please try again later.",
|
||||
})
|
||||
|
||||
let rateLimitConfig = {};
|
||||
(async () => {
|
||||
rateLimitConfig = await loadRateLimitConfig();
|
||||
Object.entries(rateLimitConfig).forEach(([route, options]) => {
|
||||
app.use(route, createLimiter(options))
|
||||
})
|
||||
app.use(route, createLimiter(<RateLimitOptions>options));
|
||||
});
|
||||
})();
|
||||
|
||||
const validateEmail = (email: string): boolean => validator.isEmail(email)
|
||||
app.get("/accounts/list", listAccounts);
|
||||
app.post("/accounts/user", getUserAccount);
|
||||
app.post("/accounts/update/password", updatePassword);
|
||||
app.post("/accounts/add", addAccount);
|
||||
|
||||
const passwordSchema = new PasswordValidator()
|
||||
passwordSchema.is().min(8).is().max(64).has().letters().has().digits().has().not().spaces()
|
||||
|
||||
const listAccountsFromDocker = async (): Promise<Account[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const container = docker.getContainer("mailserver")
|
||||
container.exec(
|
||||
{
|
||||
Cmd: ["setup", "email", "list"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
},
|
||||
(err, exec) => {
|
||||
if (err || !exec) return reject(err || new Error("Exec is undefined"))
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) return reject(err || new Error("Exec stream is undefined"))
|
||||
let output = ""
|
||||
stream.on("data", (chunk: Buffer) => (output += chunk.toString()))
|
||||
stream.on("end", () => {
|
||||
const regex = /\*\s*(\S+)\s*\(\s*([^\/]+?)\s*\/\s*([^)]+?)\s*\)\s*\[(\d+)%]/g
|
||||
const accounts: Account[] = [...output.matchAll(regex)].map((match) => ({
|
||||
email: match[1],
|
||||
used: match[2].trim() === "~" ? "Unlimited" : match[2].trim(),
|
||||
capacity: match[3].trim() === "~" ? "Unlimited" : match[3].trim(),
|
||||
percentage: match[4],
|
||||
}))
|
||||
resolve(accounts)
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const updateAccountsCache = async () => {
|
||||
const dockerAccounts = await listAccountsFromDocker();
|
||||
await db.delete(accounts);
|
||||
if (dockerAccounts.length > 0) {
|
||||
await db.insert(accounts).values(dockerAccounts);
|
||||
}
|
||||
await db
|
||||
.insert(cacheInfo)
|
||||
.values({ lastUpdated: Date.now() })
|
||||
.onConflictDoUpdate({ target: cacheInfo.id, set: { lastUpdated: Date.now() } });
|
||||
};
|
||||
|
||||
app.get("/accounts/list", async (_req, res) => {
|
||||
try {
|
||||
const cacheData = await db.select().from(cacheInfo).limit(1)
|
||||
const lastUpdated = cacheData[0]?.lastUpdated || 0
|
||||
const currentTime = Date.now()
|
||||
|
||||
if (currentTime - lastUpdated > 30 * 60 * 1000) {
|
||||
// 30 minutes
|
||||
await updateAccountsCache()
|
||||
}
|
||||
|
||||
const accountsList = await db.select().from(accounts)
|
||||
res.json({ accounts: accountsList })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message })
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/accounts/user", async (req: Request, res: Response): Promise<void> => {
|
||||
const { email } = req.body;
|
||||
if (!validateEmail(email)) {
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (account.length > 0) {
|
||||
res.json({ account: account[0] });
|
||||
const PORT = 3000;
|
||||
app.listen(PORT, () => {
|
||||
figlet('mail-connect', (err, data) => {
|
||||
if (err) {
|
||||
console.log('mail-connect');
|
||||
console.log('Version: 0.1.0');
|
||||
console.log(`API listening on port ${PORT}\n`);
|
||||
console.dir("[!] " + err);
|
||||
} else {
|
||||
res.status(404).json({ error: "Account not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: (err as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/accounts/update/password", async (req: Request, res: Response): Promise<void> => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
console.log("Error updating password: Invalid email format");
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: ["setup", "email", "update", email, password],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
reject(err || new Error("Exec stream is undefined"));
|
||||
} else {
|
||||
resolve(stream);
|
||||
console.log(data);
|
||||
console.log('Version: 0.1.0');
|
||||
console.log(`API listening on port ${PORT}\n`);
|
||||
}
|
||||
});
|
||||
});
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
await new Promise<void>((resolve) => stream.on("end", resolve));
|
||||
console.log("Docker output (update password):\n", output);
|
||||
// detect errors
|
||||
if (/ERROR/i.test(output)) {
|
||||
console.log(`Error during migration: Password reset failed`);
|
||||
res.status(500).json({ error: "Error during reset" });
|
||||
return;
|
||||
} else {
|
||||
console.log(`Reset password for account: ${email}`);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: The wait upon account creation needs to be more adaptive
|
||||
app.post("/accounts/add", async (req: Request, res: Response): Promise<void> => {
|
||||
const { email, password, migrate } = req.body;
|
||||
let failureReason = "";
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
console.log("Error adding account: Invalid email format");
|
||||
res.status(400).json({ error: "Invalid email format" });
|
||||
return;
|
||||
}
|
||||
|
||||
let finalPassword = password;
|
||||
|
||||
if (migrate) {
|
||||
try {
|
||||
const data = await fs.readFile("/app/migrate.txt", "utf8");
|
||||
const line = data.split("\n").find(l => l.trim() === email);
|
||||
if (!line) {
|
||||
failureReason = "Account not eligible";
|
||||
console.log(`Error adding account (migrate): ${failureReason}`);
|
||||
res.status(500).json({ error: "Backend error" });
|
||||
return;
|
||||
} else {
|
||||
const newData = data.replace(line, "");
|
||||
await fs.writeFile("/app/migrate.txt", newData);
|
||||
const exec = await container.exec({
|
||||
Cmd: ["setup", "email", "update", email, finalPassword],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
reject(err || new Error("Exec stream is undefined"));
|
||||
} else {
|
||||
resolve(stream);
|
||||
}
|
||||
});
|
||||
});
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
await new Promise<void>((resolve) => stream.on("end", resolve));
|
||||
console.log("Docker output (migrate update):\n", output);
|
||||
// detect errors
|
||||
if (/ERROR/i.test(output)) {
|
||||
failureReason = "Migration failed";
|
||||
console.log(`Error during migration: ${failureReason}`);
|
||||
res.status(500).json({ error: failureReason });
|
||||
return;
|
||||
} else {
|
||||
// force refresh
|
||||
await updateAccountsCache();
|
||||
const addedAccount = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (addedAccount.length > 0) {
|
||||
console.log(`Added account (via migrate): ${email}`);
|
||||
res.json({ success: true });
|
||||
return;
|
||||
} else {
|
||||
failureReason = "Migration failed";
|
||||
console.log(`Failed to migrate account: ${failureReason}`);
|
||||
res.status(500).json({ error: failureReason });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failureReason = "Backend error";
|
||||
console.log(`Error adding account (migrate branch): ${failureReason}`, error);
|
||||
res.status(500).json({ error: failureReason });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const account = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (account.length > 0) {
|
||||
console.log(`Account already exists: ${email}`);
|
||||
res.status(400).json({ error: "Account already exists" });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Error checking user: ${err}`);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalPassword || !passwordSchema.validate(finalPassword)) {
|
||||
failureReason = "Invalid or weak password";
|
||||
console.log(`Failed to add account: ${failureReason}`);
|
||||
res.status(400).json({ error: failureReason });
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-migrate account creation route
|
||||
try {
|
||||
const exec = await container.exec({
|
||||
Cmd: ["setup", "email", "add", email, finalPassword],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
|
||||
const stream = await new Promise<NodeJS.ReadableStream>((resolve, reject) => {
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
failureReason = "Failed to start Docker exec";
|
||||
console.log(`Failed to add account: ${failureReason}`, err);
|
||||
reject(err || new Error("Exec stream is undefined"));
|
||||
} else {
|
||||
resolve(stream);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
await new Promise<void>((resolve) => stream.on("end", resolve));
|
||||
|
||||
// poll db
|
||||
let accountFound = false;
|
||||
for (let attempt = 0; attempt < 15; attempt++) {
|
||||
await updateAccountsCache();
|
||||
const addedAccount = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(eq(accounts.email, email))
|
||||
.limit(1);
|
||||
if (addedAccount.length > 0) {
|
||||
accountFound = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
if (accountFound) {
|
||||
console.log(`Added account: ${email}`);
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
failureReason = "Timed out waiting for account creation";
|
||||
console.log(`Failed to add account: ${failureReason}`);
|
||||
res.status(500).json({
|
||||
error: "Check timed out waiting for account creation",
|
||||
failureReason,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failureReason = "Error executing Docker command";
|
||||
console.log(`Failed to add account: ${failureReason}`, error);
|
||||
res.status(500).json({ error: "Backend error" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 3000
|
||||
app.listen(PORT, () => console.log(`API listening on port ${PORT}`))
|
||||
|
||||
|
67
src/utils/docker.ts
Normal file
67
src/utils/docker.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import Docker from "dockerode";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const container = docker.getContainer("mailserver");
|
||||
|
||||
export interface Account {
|
||||
email: string;
|
||||
used: string;
|
||||
capacity: string;
|
||||
percentage: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export const listAccountsFromDocker = async (): Promise<Account[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
container.exec(
|
||||
{
|
||||
Cmd: ["setup", "email", "list"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
},
|
||||
(err, exec) => {
|
||||
if (err || !exec) return reject(err || new Error("Exec is undefined"));
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) return reject(err || new Error("Exec stream is undefined"));
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => (output += chunk.toString()));
|
||||
stream.on("end", () => {
|
||||
const regex = /\*\s*(\S+)\s*\(\s*([^\/]+?)\s*\/\s*([^)]+?)\s*\)\s*\[(\d+)%]/g;
|
||||
const accounts = [...output.matchAll(regex)].map((match) => ({
|
||||
email: match[1],
|
||||
used: match[2].trim() === "~" ? "Unlimited" : match[2].trim(),
|
||||
capacity: match[3].trim() === "~" ? "Unlimited" : match[3].trim(),
|
||||
percentage: match[4],
|
||||
}));
|
||||
resolve(accounts);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getExecStream = (exec: Docker.Exec): Promise<string> => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
exec.start({}, (err, stream) => {
|
||||
if (err || !stream) {
|
||||
reject(err || new Error("Exec stream is undefined"));
|
||||
} else {
|
||||
let output = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(output));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const containerExec = async (cmd: string[]) => {
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
return getExecStream(exec);
|
||||
};
|
30
src/utils/rateLimit.ts
Normal file
30
src/utils/rateLimit.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import rateLimit from "express-rate-limit";
|
||||
import fs from "fs/promises";
|
||||
|
||||
interface RateLimitOptions {
|
||||
windowMs: number;
|
||||
limit: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface RateLimitConfig {
|
||||
[endpoint: string]: RateLimitOptions;
|
||||
}
|
||||
|
||||
export const loadRateLimitConfig = async (): Promise<RateLimitConfig> => {
|
||||
try {
|
||||
await fs.access(`${process.env.MAILCONNECT_ROOT_DIR}/ratelimit.json`);
|
||||
const data = await fs.readFile(`${process.env.MAILCONNECT_ROOT_DIR}/ratelimit.json`, "utf8");
|
||||
return JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error("[!] Error loading ratelimit config:\n", err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
export const createLimiter = (options: RateLimitOptions) =>
|
||||
rateLimit({
|
||||
windowMs: options.windowMs,
|
||||
limit: options.limit,
|
||||
message: options.message || "Too many requests, please try again later.",
|
||||
});
|
17
src/utils/updateAccountsCache.ts
Normal file
17
src/utils/updateAccountsCache.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { listAccountsFromDocker } from "./docker";
|
||||
import { accounts, cacheInfo } from "../db/schema";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
const db = drizzle(process.env.DB_FILE_NAME!);
|
||||
|
||||
export const updateAccountsCache = async () => {
|
||||
const dockerAccounts = await listAccountsFromDocker();
|
||||
await db.delete(accounts);
|
||||
if (dockerAccounts.length > 0) {
|
||||
await db.insert(accounts).values(dockerAccounts);
|
||||
}
|
||||
await db
|
||||
.insert(cacheInfo)
|
||||
.values({ lastUpdated: Date.now() })
|
||||
.onConflictDoUpdate({ target: cacheInfo.id, set: { lastUpdated: Date.now() } });
|
||||
};
|
9
src/utils/validators.ts
Normal file
9
src/utils/validators.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import validator from "validator";
|
||||
import PasswordValidator from "password-validator";
|
||||
|
||||
export const validateEmail = (email: string): boolean => validator.isEmail(email);
|
||||
|
||||
export const passwordSchema = new PasswordValidator();
|
||||
passwordSchema.is().min(8).is().max(64).has().letters().has().digits().has().not().spaces();
|
||||
|
||||
export const validatePassword = (password: string): boolean => <boolean>passwordSchema.validate(password);
|
Loading…
x
Reference in New Issue
Block a user