332 lines
10 KiB
JavaScript
332 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);
|
||
|
|
});
|
||
|
|
};
|