Add logger module, services, and package files
Introduce a new logger (modules/logger.js) that prints colorized console output, writes timestamped logs to a file in a logs/ directory, strips ANSI colors for file output, and handles process exit/SIGINT cleanup. Add service stubs (services/security.js, services/storage.js, services/smtp/actions.js, services/smtp/authenticate.js) and a test.js. Add package.json and package-lock.json with runtime deps (colors, dotenv, zod) and dev tooling (eslint, nodemon) to support the new code.
This commit is contained in:
parent
1bb22413f6
commit
234af0e032
92
modules/logger.js
Normal file
92
modules/logger.js
Normal file
@ -0,0 +1,92 @@
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
require("colors")
|
||||
require("dotenv").config({
|
||||
quiet: true
|
||||
})
|
||||
|
||||
function cleanup($stream) {
|
||||
if (!$stream.destroyed) {
|
||||
$stream.end()
|
||||
}
|
||||
}
|
||||
|
||||
function write($stream, level, color, content, extraLabels = []) {
|
||||
const date = new Date().toISOString()
|
||||
const message = typeof content === "string" ? content : JSON.stringify(content, null, 2)
|
||||
|
||||
let consoleLabels = ""
|
||||
let fileLabels = ""
|
||||
|
||||
if (Array.isArray(extraLabels) && extraLabels.length > 0) {
|
||||
for (let i = 0; i < extraLabels.length; i += 2) {
|
||||
const labelName = extraLabels[i]
|
||||
const labelColor = extraLabels[i + 1]
|
||||
if (labelName) {
|
||||
fileLabels += ` [${labelName}]`
|
||||
if (labelColor && labelName[labelColor]) {
|
||||
consoleLabels += ` [${labelName[labelColor]}]`
|
||||
} else {
|
||||
consoleLabels += ` [${labelName.white}]`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${date}] `.magenta + `[${level}]`[color] + consoleLabels + " " + message)
|
||||
$stream.write(`[${date}] [${level}]${fileLabels} ${stripColors(message)}\n`)
|
||||
}
|
||||
|
||||
function createLogger(root) {
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const fileName = isTrueFromDotEnv("IS_PROD") ? new Date().toLocaleString("fr-FR", { timeZone: "UTC" }).replace(/[\/:]/g, "-").replace(/ /g, "_") : "DEV-LOG"
|
||||
|
||||
const logsDir = path.join(root, "logs")
|
||||
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true })
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(path.join(logsDir, `${fileName}.log`), { flags: "a" })
|
||||
|
||||
process.on("exit", () => {
|
||||
cleanup(stream)
|
||||
})
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
cleanup(stream)
|
||||
process.exit()
|
||||
})
|
||||
|
||||
return {
|
||||
log: (content, labels) => {
|
||||
write(stream, "INFO", "green", content, labels)
|
||||
},
|
||||
error: (content, labels) => {
|
||||
write(stream, "ERROR", "red", content, labels)
|
||||
},
|
||||
warn: (content, labels) => {
|
||||
write(stream, "WARN", "yellow", content, labels)
|
||||
},
|
||||
debug: (content, labels) => {
|
||||
write(stream, "DEBUG", "white", content, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isTrueFromDotEnv(key) {
|
||||
return (process.env[key] || "").trim().toLowerCase() === "true"
|
||||
}
|
||||
|
||||
function stripColors(string) {
|
||||
if (!string || typeof string !== "string") {
|
||||
return string
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return string.replace(/\x1B\[[0-9;]*[mK]/g, "")
|
||||
}
|
||||
|
||||
const logger = createLogger(process.cwd())
|
||||
|
||||
module.exports = logger
|
||||
1452
package-lock.json
generated
Normal file
1452
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "jawab",
|
||||
"version": "0.0.1-alpha",
|
||||
"description": "My own mail-server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.azures.fr/azures04/Jawab"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
"email": "gilleslazure04@gmail.com",
|
||||
"name": "azures04",
|
||||
"url": "https://jawab.azures.fr"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.3",
|
||||
"eslint": "^9.39.3",
|
||||
"globals": "^17.4.0",
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
44
services/security.js
Normal file
44
services/security.js
Normal file
@ -0,0 +1,44 @@
|
||||
const { z } = require("zod")
|
||||
|
||||
const LIMITS = {
|
||||
MAX_LINE_LENGTH: 512,
|
||||
MAX_MAIL_SIZE: 10 * 1024 * 1024,
|
||||
TIMEOUT_MS: 30000
|
||||
}
|
||||
|
||||
function validateEmail(email) {
|
||||
const schema = z.string().email().max(255)
|
||||
const result = schema.safeParse(email)
|
||||
return result.success
|
||||
}
|
||||
|
||||
function isLineSafe(line) {
|
||||
if (!line || line.length > LIMITS.MAX_LINE_LENGTH) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dangerousPattern = /[\0\b\v\f]/
|
||||
return !dangerousPattern.test(line)
|
||||
}
|
||||
|
||||
function sanitizePath(input) {
|
||||
return input.replace(/[^a-zA-Z0-9_\-]/g, "")
|
||||
}
|
||||
|
||||
function createSizeChecker(maxSize = LIMITS.MAX_MAIL_SIZE) {
|
||||
let currentSize = 0
|
||||
return function(chunkLength) {
|
||||
currentSize += chunkLength
|
||||
if (currentSize > maxSize) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isLineSafe,
|
||||
sanitizePath,
|
||||
validateEmail,
|
||||
createSizeChecker
|
||||
}
|
||||
208
services/smtp/actions.js
Normal file
208
services/smtp/actions.js
Normal file
@ -0,0 +1,208 @@
|
||||
const auth = require("./authenticate")
|
||||
const storage = require("../storage")
|
||||
const security = require("../security")
|
||||
|
||||
const codes = {
|
||||
220: "Service ready",
|
||||
221: "Closing transmission channel",
|
||||
235: "2.7.0 Authentication successful",
|
||||
250: "Requested mail action okay, completed",
|
||||
354: "Start mail input; end with <CR><LF>.<CR><LF>",
|
||||
421: "Service not available, closing transmission channel",
|
||||
451: "Requested action aborted: local error in processing",
|
||||
500: "Syntax error, command unrecognized",
|
||||
501: "Syntax error in parameters or arguments",
|
||||
503: "Bad sequence of commands",
|
||||
530: "5.7.0 Authentication required",
|
||||
535: "5.7.8 Authentication credentials invalid",
|
||||
550: "Requested action not taken: mailbox unavailable",
|
||||
552: "Requested mail action aborted: exceeded storage allocation"
|
||||
}
|
||||
|
||||
const features = ["Jawab Mail Server", "AUTH PLAIN", "SIZE 10485760", "HELP"]
|
||||
|
||||
function reply(socket, code, extra = null) {
|
||||
const message = codes[code]
|
||||
|
||||
if (Array.isArray(extra)) {
|
||||
for (let i = 0; i < extra.length - 1; i++) {
|
||||
socket.write(`${code}-${extra[i]}\r\n`);
|
||||
}
|
||||
return socket.write(`${code} ${extra[extra.length - 1]}\r\n`)
|
||||
}
|
||||
|
||||
const responseText = extra ? `${message} ${extra}` : message
|
||||
return socket.write(`${code} ${responseText}\r\n`)
|
||||
}
|
||||
|
||||
function handleCommand(socket, session, line) {
|
||||
if (!security.isLineSafe(line)) {
|
||||
return reply(socket, 500, "Line too long")
|
||||
}
|
||||
|
||||
const parts = line.split(" ")
|
||||
const command = parts[0].toUpperCase()
|
||||
const args = parts.slice(1).join(" ")
|
||||
|
||||
if (session.isCollectingData) {
|
||||
return handleDataStreaming(socket, session, line)
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "EHLO":
|
||||
case "HELO":
|
||||
handleHelo(socket, session, args)
|
||||
break
|
||||
case "AUTH":
|
||||
handleAuth(socket, session, args)
|
||||
break
|
||||
case "MAIL":
|
||||
handleMailFrom(socket, session, args)
|
||||
break
|
||||
case "RCPT":
|
||||
handleRcptTo(socket, session, args)
|
||||
break
|
||||
case "DATA":
|
||||
handleDataStart(socket, session)
|
||||
break
|
||||
case "RSET":
|
||||
handleReset(socket, session)
|
||||
break
|
||||
case "QUIT":
|
||||
handleQuit(socket)
|
||||
break
|
||||
case "NOOP":
|
||||
reply(socket, 250)
|
||||
break
|
||||
default:
|
||||
reply(socket, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuth(socket, session, args) {
|
||||
const [mechanism, payload] = args.split(" ")
|
||||
|
||||
if (mechanism !== "PLAIN" || !payload) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
const { identity, secret } = auth.decodeSASL(payload)
|
||||
|
||||
if (auth.authenticate(identity, secret)) {
|
||||
session.authenticated = true
|
||||
session.user = identity
|
||||
return reply(socket, 235)
|
||||
} else {
|
||||
return reply(socket, 535)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMailFrom(socket, session, args) {
|
||||
if (!session.authenticated) {
|
||||
return reply(socket, 530)
|
||||
}
|
||||
|
||||
const emailMatch = args.match(/FROM:\s*<(.+?)>/i)
|
||||
if (!emailMatch) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
const email = emailMatch[1]
|
||||
|
||||
if (!security.validateEmail(email)) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
session.from = email
|
||||
session.sizeChecker = security.createSizeChecker()
|
||||
return reply(socket, "250")
|
||||
}
|
||||
|
||||
function handleRcptTo(socket, session, args) {
|
||||
if (!session.from) {
|
||||
return reply(socket, 503)
|
||||
}
|
||||
|
||||
const emailMatch = args.match(/TO:\s*<(.+?)>/i)
|
||||
if (!emailMatch) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
const email = emailMatch[1]
|
||||
|
||||
if (!security.validateEmail(email)) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
session.to = email
|
||||
return reply(socket, "250")
|
||||
}
|
||||
|
||||
function handleDataStart(socket, session) {
|
||||
if (!session.from || !session.to) return reply(socket, 503)
|
||||
|
||||
const { writeStream } = storage.createMailWriteStream()
|
||||
|
||||
session.isCollectingData = true
|
||||
session.currentWriteStream = writeStream
|
||||
|
||||
return reply(socket, 354)
|
||||
}
|
||||
|
||||
function handleDataStart(socket, session) {
|
||||
if (!session.from || !session.to) return reply(socket, 503)
|
||||
|
||||
const { writeStream } = storage.createMailWriteStream()
|
||||
session.currentWriteStream = writeStream
|
||||
session.sizeChecker = security.createSizeChecker()
|
||||
session.isCollectingData = true
|
||||
|
||||
return reply(socket, 354)
|
||||
}
|
||||
|
||||
function handleDataStreaming(socket, session, line) {
|
||||
if (line.trim() === ".") {
|
||||
session.isCollectingData = false
|
||||
session.currentWriteStream.end()
|
||||
session.currentWriteStream = null
|
||||
return reply(socket, 250)
|
||||
}
|
||||
|
||||
if (session.sizeChecker && !session.sizeChecker(line.length)) {
|
||||
session.isCollectingData = false
|
||||
session.currentWriteStream.destroy()
|
||||
return reply(socket, 552)
|
||||
}
|
||||
|
||||
return session.currentWriteStream.write(line + "\n")
|
||||
}
|
||||
|
||||
function handleReset(socket, session) {
|
||||
if (session.currentWriteStream) {
|
||||
session.currentWriteStream.destroy()
|
||||
}
|
||||
session.from = null
|
||||
session.to = null
|
||||
session.isCollectingData = false
|
||||
return reply(socket, 250)
|
||||
}
|
||||
|
||||
function handleQuit(socket) {
|
||||
reply(socket, 221)
|
||||
return socket.end()
|
||||
}
|
||||
|
||||
function handleHelo(socket, session, args) {
|
||||
if (!args) {
|
||||
return reply(socket, 501)
|
||||
}
|
||||
|
||||
session.helo = args
|
||||
session.hasHelo = true
|
||||
|
||||
return reply(socket, 250, features)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleCommand
|
||||
}
|
||||
47
services/smtp/authenticate.js
Normal file
47
services/smtp/authenticate.js
Normal file
@ -0,0 +1,47 @@
|
||||
const crypto = require("node:crypto")
|
||||
|
||||
function decodeSASL(base64Payload) {
|
||||
try {
|
||||
const buffer = Buffer.from(base64Payload, "base64")
|
||||
const parts = [];
|
||||
let lastPos = 0;
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
parts.push(buffer.slice(lastPos, i).toString("utf8"))
|
||||
lastPos = i + 1
|
||||
}
|
||||
}
|
||||
parts.push(buffer.slice(lastPos).toString("utf8"))
|
||||
|
||||
return { user: parts[1], pass: parts[2] }
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeCompare(input, actual) {
|
||||
if (!input || !actual || input.length !== actual.length) {
|
||||
return false
|
||||
}
|
||||
return crypto.timingSafeEqual(Buffer.from(input), Buffer.from(actual))
|
||||
}
|
||||
|
||||
function authenticate(identity, secret, type = "PASSWORD") {
|
||||
const mockUser = {
|
||||
username: "azures",
|
||||
password: "Password123@",
|
||||
token: "tanit_tk_998877"
|
||||
};
|
||||
|
||||
if (type === "TOKEN") {
|
||||
return safeCompare(secret, mockUser.token)
|
||||
}
|
||||
|
||||
return safeCompare(identity, mockUser.username) && safeCompare(secret, mockUser.password)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decodeSASL,
|
||||
authenticate
|
||||
}
|
||||
65
services/storage.js
Normal file
65
services/storage.js
Normal file
@ -0,0 +1,65 @@
|
||||
const fs = require("node:fs");
|
||||
const fsPromises = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const logger = require("../modules/logger");
|
||||
|
||||
const BASE_PATH = path.resolve(process.env.MAIL_STORAGE || process.cwd(), "storage")
|
||||
|
||||
function initialize() {
|
||||
const directories = ["", "new", "cur", "tmp"]
|
||||
for (const dir of directories) {
|
||||
const fullPath = path.join(BASE_PATH, dir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.mkdirSync(fullPath, { recursive: true })
|
||||
}
|
||||
}
|
||||
logger.log("Directories initialized", ["StorageService", "cyan"])
|
||||
}
|
||||
|
||||
function createMailWriteStream() {
|
||||
const fileName = `${Date.now()}.${process.pid}.jawab`
|
||||
const tempPath = path.join(BASE_PATH, "tmp", fileName)
|
||||
const finalPath = path.join(BASE_PATH, "new", fileName)
|
||||
|
||||
const writeStream = fs.createWriteStream(tempPath)
|
||||
|
||||
writeStream.on("finish", async () => {
|
||||
try {
|
||||
await fsPromises.rename(tempPath, finalPath)
|
||||
} catch (err) {
|
||||
logger.log(`Rename failed: ${err.message}`, ["StorageService", "cyan"]);
|
||||
}
|
||||
})
|
||||
|
||||
return { writeStream, fileName }
|
||||
}
|
||||
|
||||
async function listNewMails() {
|
||||
try {
|
||||
const files = await fsPromises.readdir(path.join(BASE_PATH, "new"))
|
||||
return files.map(file => ({
|
||||
id: file,
|
||||
path: path.join(BASE_PATH, "new", file),
|
||||
status: "new"
|
||||
}))
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function getMailContent(fileName, folder = "new") {
|
||||
const fullPath = path.join(BASE_PATH, folder, fileName);
|
||||
try {
|
||||
await fsPromises.access(fullPath, fs.constants.R_OK)
|
||||
return fs.createReadStream(fullPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
listNewMails,
|
||||
getMailContent,
|
||||
createMailWriteStream
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user