mailbox/backend/services/smtpService.js
2025-12-16 08:18:15 +09:00

331 lines
10 KiB
JavaScript

/**
* SMTP 서비스
* 외부에서 들어오는 이메일을 수신하여 DB에 저장
* 접속 IP와 국가 정보를 SmtpLog에 기록
* rspamd를 통한 스팸 검사 지원
*/
const { SMTPServer } = require("smtp-server");
const { simpleParser } = require("mailparser");
const Inbox = require("../models/Inbox");
const Spam = require("../models/Spam");
const Sent = require("../models/Sent");
const SystemConfig = require("../models/SystemConfig");
const SmtpLog = require("../models/SmtpLog");
const { uploadAttachment } = require("./s3Service");
const rspamd = require("./rspamdService");
const { calculateEmailSize } = require("../utils/emailUtils");
/**
* IP 주소로 국가 정보 조회 (무료 GeoIP API 사용)
*/
const getCountryFromIP = async (ip) => {
try {
// 로컬/프라이빗 IP 처리
if (
ip === "::1" ||
ip === "127.0.0.1" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
ip.startsWith("172.")
) {
return { country: "LOCAL", countryName: "Local Network" };
}
// IPv6 매핑된 IPv4 주소 처리
const cleanIp = ip.replace(/^::ffff:/, "");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3초 타임아웃
const response = await fetch(
`http://ip-api.com/json/${cleanIp}?fields=status,country,countryCode`,
{
signal: controller.signal,
}
);
clearTimeout(timeout);
const data = await response.json();
if (data.status === "success") {
return { country: data.countryCode, countryName: data.country };
}
return { country: null, countryName: null };
} catch {
return { country: null, countryName: null };
}
};
/**
* 스토리지 쿼터 확인
*/
const checkStorageQuota = async () => {
try {
const quotaConfig = await SystemConfig.findOne({
where: { key: "user_storage_quota" },
});
const limitMB = quotaConfig ? parseInt(quotaConfig.value) : 51200;
const limitBytes = limitMB * 1024 * 1024;
const [inboxItems, sentItems] = await Promise.all([
Inbox.findAll({ attributes: ["attachments", "subject", "text", "html"] }),
Sent.findAll({ attributes: ["attachments", "subject", "text", "html"] }),
]);
let totalSize = 0;
const calculate = (items) => {
items.forEach((item) => {
if (item.subject) totalSize += Buffer.byteLength(item.subject, "utf8");
if (item.text) totalSize += Buffer.byteLength(item.text, "utf8");
if (item.html) totalSize += Buffer.byteLength(item.html, "utf8");
let atts = item.attachments;
if (typeof atts === "string") {
try {
atts = JSON.parse(atts);
if (typeof atts === "string") atts = JSON.parse(atts);
} catch {
atts = [];
}
}
if (Array.isArray(atts)) {
atts.forEach((a) => (totalSize += a.size || 0));
}
});
};
calculate(inboxItems);
calculate(sentItems);
return totalSize >= limitBytes;
} catch (error) {
console.error("[SMTP] 쿼터 확인 실패:", error);
return false;
}
};
// 허용된 도메인 목록 (수신 가능한 도메인)
const ALLOWED_DOMAINS = ["caadiq.co.kr"];
// SMTP 서버 설정
const server = new SMTPServer({
logger: true,
authOptional: true,
/**
* 수신자(RCPT TO) 검증
* 허용된 도메인으로 오는 메일만 수락하고, 그 외는 거부
*/
onRcptTo(address, session, callback) {
const recipientEmail = address.address.toLowerCase();
const recipientDomain = recipientEmail.split("@")[1];
// 허용된 도메인인지 확인
if (!recipientDomain || !ALLOWED_DOMAINS.includes(recipientDomain)) {
console.warn(
`[SMTP] 거부 - 허용되지 않은 도메인: ${recipientEmail} (도메인: ${recipientDomain})`
);
return callback(
new Error(`550 5.1.1 Recipient domain not allowed: ${recipientDomain}`)
);
}
console.log(`[SMTP] 수신자 승인: ${recipientEmail}`);
return callback();
},
async onMailFrom(address, session, callback) {
const isFull = await checkStorageQuota();
if (isFull) {
console.warn(`[SMTP] 저장 공간 부족으로 거부: ${address.address}`);
return callback(new Error("552 5.2.2 Storage quota exceeded"));
}
return callback();
},
onConnect(session, callback) {
const remoteAddress = session.remoteAddress || "unknown";
console.log(`[SMTP] 연결: ${remoteAddress}`);
session._remoteIp = remoteAddress;
callback();
},
onData(stream, session, callback) {
const fromAddr = session.envelope.mailFrom?.address || "unknown";
const toAddrs =
session.envelope.rcptTo?.map((r) => r.address).join(", ") || "unknown";
const remoteAddress =
session._remoteIp || session.remoteAddress || "unknown";
console.log(
`[SMTP] 메일 수신: ${fromAddr}${toAddrs} (from ${remoteAddress})`
);
// 원본 데이터 수집 (rspamd 검사용)
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", async () => {
const rawEmail = Buffer.concat(chunks).toString("utf8");
// 먼저 simpleParser로 파싱
let parsed;
try {
parsed = await simpleParser(rawEmail);
} catch (err) {
console.error("[SMTP] 파싱 오류:", err);
try {
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: false,
connectedAt: new Date(),
});
} catch {}
return callback(new Error("Message parse error"));
}
try {
// rspamd 스팸 검사
let spamResult = { isSpam: false, score: 0, action: "no action" };
try {
spamResult = await rspamd.checkSpam(rawEmail);
console.log(
`[SMTP] rspamd 검사: isSpam=${spamResult.isSpam}, score=${spamResult.score}`
);
} catch (rspamdError) {
console.warn(
"[SMTP] rspamd 검사 실패 (스팸 아님으로 처리):",
rspamdError.message
);
}
// 첨부파일 처리
const attachmentsData = [];
console.log(
`[SMTP] 파싱된 첨부파일 개수: ${parsed.attachments?.length || 0}`
);
if (parsed.attachments && parsed.attachments.length > 0) {
console.log(
`[SMTP] 첨부파일 정보:`,
parsed.attachments.map((a) => ({
filename: a.filename,
contentType: a.contentType,
size: a.size,
}))
);
for (const att of parsed.attachments) {
const key = await uploadAttachment(
att.content,
att.filename,
att.contentType
);
attachmentsData.push({
filename: att.filename,
contentType: att.contentType,
size: att.size,
key: key,
});
}
}
// 이메일 데이터 준비
// parsed.from 구조: { text: "이름 <email>", value: [{ name, address }] }
const fromValue = parsed.from?.value?.[0];
const fromEmail = fromValue?.address || fromAddr;
const fromName = fromValue?.name || null;
const emailData = {
from: fromEmail,
fromName: fromName,
to: parsed.to?.text || toAddrs,
subject: parsed.subject || "(제목 없음)",
text: parsed.text,
html: parsed.html || parsed.textAsHtml,
attachments: attachmentsData,
messageId: parsed.messageId,
date: parsed.date || new Date(),
isRead: false,
spamScore: spamResult.score,
rawEmail: rawEmail, // 원본 저장 (학습용)
};
// 메일 크기 계산
emailData.size = calculateEmailSize(emailData);
// 스팸 여부에 따라 저장 위치 결정
let email;
if (spamResult.isSpam) {
emailData.originalMailbox = "INBOX";
email = await Spam.create(emailData);
console.log(
`[SMTP] 스팸으로 분류: ${email.id} - ${email.subject} (score: ${spamResult.score})`
);
} else {
email = await Inbox.create(emailData);
console.log(`[SMTP] 저장 완료: ${email.id} - ${email.subject}`);
}
// 국가 정보 조회 및 접속 로그 기록
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
hostname: session.hostNameAppearsAs || null,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: true,
isSpam: spamResult.isSpam,
spamScore: spamResult.score,
connectedAt: new Date(),
});
// SSE를 통해 실시간 알림 전송 (스팸이 아닌 경우만)
if (!spamResult.isSpam) {
const sseService = require("./sseService");
sseService.notifyNewMail({
from: email.from,
to: email.to,
subject: email.subject,
date: email.date,
});
}
callback();
} catch (dbError) {
console.error("[SMTP] DB 오류:", dbError);
try {
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: false,
connectedAt: new Date(),
});
} catch {}
callback(new Error("Internal storage error"));
}
});
stream.on("error", (err) => {
console.error("[SMTP] 스트림 오류:", err);
callback(new Error("Stream error"));
});
},
});
exports.startSMTPServer = () => {
const port = 25;
server.listen(port, () => {
console.log(`[SMTP] 서버 시작: 포트 ${port}`);
});
server.on("error", (err) => {
console.error("[SMTP] 서버 오류:", err);
});
};