/** * 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: "이름 ", 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); }); };