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:
Gilles Lazures 2026-03-06 11:21:23 +01:00
parent 1bb22413f6
commit 234af0e032
7 changed files with 1939 additions and 0 deletions

92
modules/logger.js Normal file
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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
View 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
}

View 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
View 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
}