/** * 메일 라우터 * 메일 CRUD, 읽음/별표 처리, 휴지통 관리, 첨부파일 다운로드 등 */ const express = require("express"); const router = express.Router(); const { Op } = require("sequelize"); const sequelize = require("../config/database"); const SentLog = require("../models/SentLog"); const SystemConfig = require("../models/SystemConfig"); const User = require("../models/User"); const { getObjectStream } = require("../services/s3Service"); const { sendEmail } = require("../services/emailService"); const sseService = require("../services/sseService"); const { authenticateToken } = require("../middleware/auth"); const jwt = require("jsonwebtoken"); // 공통 헬퍼 함수 import const { getModel, normalizeEmail, calculateStorageSize, findEmail, safeRollback, MAILBOX_MODELS, } = require("../utils/helpers"); const { calculateEmailSize } = require("../utils/emailUtils"); // 자주 사용하는 모델 destructuring const { INBOX: Inbox, SENT: Sent, TRASH: Trash, SPAM: Spam, DRAFTS: Draft, IMPORTANT: Important, } = MAILBOX_MODELS; /** * SSE - 실시간 메일 알림 (인증 미들웨어 전에 배치) */ router.get("/events", (req, res) => { // 쿼리 파라미터에서 토큰 확인 const token = req.query.token; if (!token) { return res.status(401).json({ error: "토큰이 필요합니다" }); } try { // 토큰 검증 jwt.verify(token, process.env.JWT_SECRET || "your-secret-key"); // SSE 헤더 설정 res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("Access-Control-Allow-Origin", "*"); // 연결 유지 메시지 res.write('data: {"type":"connected"}\n\n'); // 클라이언트 추가 sseService.addClient(res); // 연결 종료 시 클라이언트 제거 req.on("close", () => { sseService.removeClient(res); }); } catch (error) { return res.status(401).json({ error: "유효하지 않은 토큰입니다" }); } }); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); // ============================================================================ // Gemini 번역 API (로그인 사용자 모두 사용 가능) // ============================================================================ const geminiService = require("../services/geminiService"); const EmailTranslation = require("../models/EmailTranslation"); /** * Gemini 현재 모델명 조회 (일반 사용자용) * GET /api/emails/gemini-model */ router.get("/emails/gemini-model", async (req, res) => { try { const config = await geminiService.getGeminiConfig(); const modelInfo = config.models?.find((m) => m.id === config.gemini_model); res.json({ modelId: config.gemini_model, modelName: modelInfo?.name || config.gemini_model || "Gemini AI", }); } catch (error) { res.json({ modelId: "", modelName: "Gemini AI" }); } }); /** * Gemini 번역 (캐시 지원) * POST /api/emails/translate * Body: { emailId, mailbox, text, targetLang } */ router.post("/emails/translate", async (req, res) => { try { const { emailId, mailbox, text, targetLang } = req.body; const lang = targetLang || "ko"; if (!text) { return res.status(400).json({ error: "번역할 텍스트가 필요합니다" }); } // emailId와 mailbox가 있으면 캐시 확인 if (emailId && mailbox) { const cached = await EmailTranslation.findOne({ where: { emailId, mailbox, targetLang: lang }, }); if (cached) { return res.json({ translatedText: cached.translatedContent, cached: true, modelName: cached.modelUsed || "Gemini AI", }); } } // 캐시가 없으면 번역 실행 const config = await geminiService.getGeminiConfig(); if (!config.gemini_api_key) { return res.status(400).json({ error: "Gemini API 키가 설정되지 않았습니다. 관리자에게 문의하세요.", }); } const translatedText = await geminiService.translateText( text, lang, config.gemini_api_key, config.gemini_model ); // 번역 결과 캐시 저장 (emailId, mailbox가 있을 때만) if (emailId && mailbox) { await EmailTranslation.upsert({ emailId, mailbox, targetLang: lang, translatedContent: translatedText, modelUsed: config.gemini_model, }); } // 모델명 조회 const modelInfo = config.models?.find((m) => m.id === config.gemini_model); const modelName = modelInfo?.name || config.gemini_model; res.json({ translatedText, cached: false, modelName }); } catch (error) { console.error("번역 오류:", error); res.status(500).json({ error: error.message || "번역 실패" }); } }); // ============================================================================ // API 라우트 // ============================================================================ /** * 메일함별 개수 및 스토리지 사용량 조회 * GET /api/emails/counts */ router.get("/emails/counts", async (req, res) => { try { const userEmail = req.user.email; // 각 메일함 개수 병렬 조회 const [inboxC, sentC, trashC, spamC, draftsC, importantC] = await Promise.all([ Inbox.count({ where: { to: { [Op.like]: `%${userEmail}%` }, isDeleted: false }, }), Sent.count({ where: { from: userEmail, isDeleted: false } }), Trash.count({ where: { [Op.or]: [ { from: userEmail }, { to: { [Op.like]: `%${userEmail}%` } }, ], }, }), Spam.count({ where: { to: { [Op.like]: `%${userEmail}%` }, isDeleted: false }, }), Draft.count({ where: { from: userEmail, isDeleted: false } }), Important.count({ where: { [Op.or]: [ { from: userEmail }, { to: { [Op.like]: `%${userEmail}%` } }, ], }, }), ]); // 스토리지 사용량 계산 (전체 시스템) const [inboxItems, sentItems] = await Promise.all([ Inbox.findAll({ attributes: ["attachments", "subject", "text", "html"] }), Sent.findAll({ attributes: ["attachments", "subject", "text", "html"] }), ]); const totalSize = calculateStorageSize(inboxItems) + calculateStorageSize(sentItems); const storageUsedMB = (totalSize / (1024 * 1024)).toFixed(2); // 쿼터 설정 가져오기 (기본값 50GB) const quotaConfig = await SystemConfig.findOne({ where: { key: "user_storage_quota" }, }); const storageLimit = quotaConfig ? parseInt(quotaConfig.value) : 51200; res.json({ INBOX: inboxC, SENT: sentC, TRASH: trashC, SPAM: spamC, DRAFTS: draftsC, IMPORTANT: importantC, storageUsed: storageUsedMB, storageLimit: storageLimit, }); } catch (error) { console.error("메일함 개수 조회 오류:", error); res.status(500).json({ error: "개수 조회 실패" }); } }); /** * 메일 검색 (상세 필터 지원) * GET /api/emails/search * 쿼리 파라미터: * - q: 기본 검색어 * - scope: 검색 범위 (ALL|INBOX|SENT|...) * - from: 발신자 필터 * - to: 수신자 필터 * - subject: 제목 필터 * - includes: 포함 키워드 * - excludes: 제외 키워드 * - minSize: 최소 크기 (bytes) * - maxSize: 최대 크기 (bytes) * - dateAfter: 이후 날짜 (YYYY-MM-DD) * - dateBefore: 이전 날짜 (YYYY-MM-DD) * - hasAttachment: 첨부파일 유무 (true/false) */ router.get("/emails/search", async (req, res) => { try { const { q, scope = "ALL", from: fromFilter, to: toFilter, subject: subjectFilter, includes, excludes, minSize, maxSize, dateAfter, dateBefore, hasAttachment, page = 1, limit = 50, } = req.query; const userEmail = req.user.email; // 최소한 하나의 검색 조건이 필요 const hasAnyFilter = q || fromFilter || toFilter || subjectFilter || includes || minSize || maxSize || dateAfter || dateBefore || hasAttachment; if (!hasAnyFilter) { return res.json({ emails: [], total: 0, page: 1, totalPages: 0 }); } const searchQuery = q ? q.trim() : ""; const pageNum = parseInt(page) || 1; const limitNum = parseInt(limit) || 50; const offset = (pageNum - 1) * limitNum; // 검색할 메일함 결정 const mailboxesToSearch = scope === "ALL" ? ["INBOX", "SENT", "DRAFTS", "SPAM", "TRASH", "IMPORTANT"] : [scope.toUpperCase()]; let allResults = []; // 각 메일함에서 검색 for (const box of mailboxesToSearch) { const Model = getModel(box); if (!Model) continue; // 사용자별 기본 필터 let userFilter = {}; if (box === "INBOX" || box === "SPAM") { userFilter.to = { [Op.like]: `%${userEmail}%` }; } else if (box === "SENT" || box === "DRAFTS") { userFilter.from = userEmail; } else { userFilter[Op.or] = [ { from: userEmail }, { to: { [Op.like]: `%${userEmail}%` } }, ]; } // 검색 조건 배열 const andConditions = []; // 기본 검색어 (제목, 발신자, 수신자, 본문 검색) if (searchQuery) { andConditions.push({ [Op.or]: [ { subject: { [Op.like]: `%${searchQuery}%` } }, { from: { [Op.like]: `%${searchQuery}%` } }, { fromName: { [Op.like]: `%${searchQuery}%` } }, { to: { [Op.like]: `%${searchQuery}%` } }, { text: { [Op.like]: `%${searchQuery}%` } }, { html: { [Op.like]: `%${searchQuery}%` } }, ], }); } // 발신자 필터 if (fromFilter) { andConditions.push({ [Op.or]: [ { from: { [Op.like]: `%${fromFilter}%` } }, { fromName: { [Op.like]: `%${fromFilter}%` } }, ], }); } // 수신자 필터 if (toFilter) { andConditions.push({ to: { [Op.like]: `%${toFilter}%` } }); } // 제목 필터 if (subjectFilter) { andConditions.push({ subject: { [Op.like]: `%${subjectFilter}%` } }); } // 포함 키워드 if (includes) { andConditions.push({ [Op.or]: [ { text: { [Op.like]: `%${includes}%` } }, { html: { [Op.like]: `%${includes}%` } }, { subject: { [Op.like]: `%${includes}%` } }, ], }); } // 날짜 필터 if (dateAfter) { andConditions.push({ date: { [Op.gte]: new Date(dateAfter) } }); } if (dateBefore) { andConditions.push({ date: { [Op.lte]: new Date(dateBefore + "T23:59:59") }, }); } const where = { ...userFilter, isDeleted: false, }; if (andConditions.length > 0) { where[Op.and] = andConditions; } let results = await Model.findAll({ where, order: [["date", "DESC"]], }); // 제외 키워드 (메모리에서 필터링) if (excludes) { const excludeTerms = excludes.toLowerCase(); results = results.filter((email) => { const content = `${email.subject || ""} ${email.text || ""} ${ email.html || "" }`.toLowerCase(); return !content.includes(excludeTerms); }); } // 크기 필터 (DB size 컬럼 우선 사용, 없는 메일은 메모리에서 계산) if (minSize || maxSize) { const minBytes = minSize ? parseInt(minSize) : 0; const maxBytes = maxSize ? parseInt(maxSize) : Infinity; results = results.filter((email) => { // size 컬럼이 있으면 사용 if ( email.size !== null && email.size !== undefined && email.size > 0 ) { return email.size >= minBytes && email.size <= maxBytes; } // size 컬럼이 없는 기존 메일은 메모리에서 계산 (fallback) let size = 0; if (email.subject) size += Buffer.byteLength(email.subject, "utf8"); if (email.text) size += Buffer.byteLength(email.text, "utf8"); if (email.html) size += Buffer.byteLength(email.html, "utf8"); try { const atts = typeof email.attachments === "string" ? JSON.parse(email.attachments) : email.attachments || []; atts.forEach((att) => { size += att.size || 0; }); } catch {} return size >= minBytes && size <= maxBytes; }); } // 첨부파일 필터 (메모리에서 필터링) if (hasAttachment === "true") { results = results.filter((email) => { try { const atts = typeof email.attachments === "string" ? JSON.parse(email.attachments) : email.attachments || []; return Array.isArray(atts) && atts.length > 0; } catch { return false; } }); } // 메일함 정보 추가 results.forEach((email) => { const normalized = normalizeEmail(email, box); normalized.mailbox = box; allResults.push(normalized); }); } // 날짜순 정렬 allResults.sort((a, b) => new Date(b.date) - new Date(a.date)); // 페이징 const total = allResults.length; const paged = allResults.slice(offset, offset + limitNum); res.json({ emails: paged, total, page: pageNum, totalPages: Math.ceil(total / limitNum), query: searchQuery, scope, }); } catch (error) { console.error("메일 검색 오류:", error); res.status(500).json({ error: "검색 실패" }); } }); /** * 메일 목록 조회 (페이징) * GET /api/emails?mailbox=INBOX&page=1&limit=20 */ router.get("/emails", async (req, res) => { try { const { mailbox = "INBOX", page = 1, limit = 20 } = req.query; const Model = getModel(mailbox); const userEmail = req.user.email; const pageNum = parseInt(page) || 1; const limitNum = parseInt(limit) || 20; const offset = (pageNum - 1) * limitNum; // 사용자별 필터링 조건 let where = { isDeleted: false }; if (mailbox === "INBOX" || mailbox === "SPAM") { where.to = { [Op.like]: `%${userEmail}%` }; } else if (mailbox === "SENT" || mailbox === "DRAFTS") { where.from = userEmail; } else { where[Op.or] = [ { from: userEmail }, { to: { [Op.like]: `%${userEmail}%` } }, ]; } const { count, rows } = await Model.findAndCountAll({ where, order: [["date", "DESC"]], limit: limitNum, offset: offset, }); res.json({ emails: rows.map((e) => normalizeEmail(e, mailbox)), total: count, page: pageNum, totalPages: Math.ceil(count / limitNum), }); } catch (error) { console.error("메일 목록 조회 오류:", error); res.status(500).json({ error: "메일 조회 실패" }); } }); /** * 메일 상세 조회 * GET /api/emails/:id?mailbox=INBOX */ router.get("/emails/:id", async (req, res) => { try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) return res.status(404).json({ error: "메일 찾기 실패" }); res.json(normalizeEmail(result.email, result.box)); } catch (error) { console.error("메일 상세 조회 오류:", error); res.status(500).json({ error: "오류" }); } }); /** * 읽음 처리 * PATCH /api/emails/:id/read?mailbox=INBOX */ router.patch("/emails/:id/read", async (req, res) => { try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) return res.status(404).json({ error: "메일 찾기 실패" }); result.email.isRead = true; // \\Seen 플래그 추가 let flags = result.email.flags || []; if (typeof flags === "string") { try { flags = JSON.parse(flags); } catch { flags = []; } } if (!Array.isArray(flags)) flags = []; if (!flags.includes("\\Seen")) { flags.push("\\Seen"); } result.email.flags = JSON.stringify(flags); await result.email.save(); res.json({ success: true }); } catch (error) { console.error("읽음 처리 오류:", error); res.status(500).json({ error: "오류" }); } }); /** * 안읽음 처리 * PATCH /api/emails/:id/unread?mailbox=INBOX */ router.patch("/emails/:id/unread", async (req, res) => { try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) return res.status(404).json({ error: "메일 찾기 실패" }); result.email.isRead = false; // \Seen 플래그 제거 let flags = result.email.flags || []; if (typeof flags === "string") { try { flags = JSON.parse(flags); } catch { flags = []; } } if (!Array.isArray(flags)) flags = []; result.email.flags = JSON.stringify(flags.filter((f) => f !== "\\Seen")); await result.email.save(); res.json({ success: true }); } catch (error) { console.error("안읽음 처리 오류:", error); res.status(500).json({ error: "오류" }); } }); /** * 별표(중요) 토글 - 이동 방식 * PATCH /api/emails/:id/star?mailbox=INBOX * - 별표 추가: 원본 메일함 → Important로 이동 * - 별표 해제: Important → 원래 메일함으로 이동 */ router.patch("/emails/:id/star", async (req, res) => { const t = await sequelize.transaction(); try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) { await safeRollback(t); return res.status(404).json({ error: "메일 찾기 실패" }); } const { email, box } = result; // flags 파싱 let flags = email.flags || []; if (typeof flags === "string") { try { flags = JSON.parse(flags); } catch { flags = []; } } if (!Array.isArray(flags)) flags = []; const isFlagged = flags.includes("\\Flagged"); if (box === "IMPORTANT") { // 중요편지함에서 별표 해제 → 원래 메일함으로 복원 flags = flags.filter((f) => f !== "\\Flagged"); const originalBox = email.originalMailbox || "INBOX"; const TargetModel = getModel(originalBox); // 원래 메일함으로 복사 const emailData = email.toJSON(); delete emailData.id; delete emailData.originalMailbox; emailData.flags = flags; const newEmail = await TargetModel.create(emailData, { transaction: t }); // Important에서 삭제 await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true, starred: false, movedTo: originalBox.toLowerCase(), newEmailId: newEmail.id, }); } else { // 다른 메일함에서 별표 추가 → Important로 이동 flags.push("\\Flagged"); // Important로 복사 const emailData = email.toJSON(); delete emailData.id; emailData.originalMailbox = box; emailData.flags = flags; const newEmail = await Important.create(emailData, { transaction: t }); // 원본 삭제 await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true, starred: true, movedTo: "important", newEmailId: newEmail.id, }); } } catch (error) { await safeRollback(t); console.error("별표 처리 오류:", error); res.status(500).json({ error: "오류" }); } }); /** * 휴지통으로 이동 (트랜잭션 사용) * PATCH /api/emails/:id/trash?mailbox=INBOX */ router.patch("/emails/:id/trash", async (req, res) => { const t = await sequelize.transaction(); try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) { await safeRollback(t); return res.status(404).json({ error: "메일 찾기 실패" }); } const { email, box } = result; if (box === "TRASH") { await safeRollback(t); return res.json({ success: true }); } // 휴지통으로 복사 const emailData = email.toJSON(); delete emailData.id; emailData.originalMailbox = box; if (emailData.isRead === undefined) emailData.isRead = true; const trashEmail = await Trash.create(emailData, { transaction: t }); await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true, trashId: trashEmail.id }); } catch (error) { await safeRollback(t); console.error("휴지통 이동 오류:", error); res.status(500).json({ error: "휴지통 이동 실패" }); } }); /** * 휴지통에서 복구 (트랜잭션 사용) * PATCH /api/emails/:id/restore?mailbox=TRASH */ router.patch("/emails/:id/restore", async (req, res) => { const t = await sequelize.transaction(); try { const { mailbox } = req.query; const result = await findEmail( req.params.id, req.user.email, mailbox || "TRASH" ); if (!result || result.box !== "TRASH") { await safeRollback(t); return res.status(404).json({ error: "휴지통에서 찾을 수 없음" }); } const { email } = result; const targetBox = email.originalMailbox || "INBOX"; const TargetModel = getModel(targetBox); const emailData = email.toJSON(); delete emailData.id; delete emailData.originalMailbox; if (emailData.isRead === undefined) emailData.isRead = true; await TargetModel.create(emailData, { transaction: t }); await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true }); } catch (error) { await safeRollback(t); console.error("복구 오류:", error); res.status(500).json({ error: "복구 실패" }); } }); /** * 스팸함으로 이동 (트랜잭션 사용) + rspamd 학습 * PATCH /api/emails/:id/spam?mailbox=INBOX */ router.patch("/emails/:id/spam", async (req, res) => { const t = await sequelize.transaction(); try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) { await safeRollback(t); return res.status(404).json({ error: "메일 찾기 실패" }); } const { email, box } = result; if (box === "SPAM") { await safeRollback(t); return res.json({ success: true }); } // rspamd 스팸 학습 (원본 이메일이 있는 경우) try { const rspamd = require("../services/rspamdService"); const rawEmail = email.rawEmail || rspamd.buildRawEmail(email.toJSON()); await rspamd.learnSpam(rawEmail); console.log(`[rspamd] 스팸 학습 완료: ${email.subject}`); } catch (rspamdError) { console.warn("[rspamd] 스팸 학습 실패 (무시):", rspamdError.message); } // 스팸함으로 복사 const emailData = email.toJSON(); delete emailData.id; emailData.originalMailbox = box; if (emailData.isRead === undefined) emailData.isRead = true; const spamEmail = await Spam.create(emailData, { transaction: t }); await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true, spamId: spamEmail.id, learned: true }); } catch (error) { await safeRollback(t); console.error("스팸함 이동 오류:", error); res.status(500).json({ error: "스팸함 이동 실패" }); } }); /** * 메일 이동 (범용) + rspamd 학습 * PATCH /api/emails/:id/move?mailbox=INBOX&target=SPAM */ router.patch("/emails/:id/move", async (req, res) => { const t = await sequelize.transaction(); try { const { mailbox, target } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) { await safeRollback(t); return res.status(404).json({ error: "메일 찾기 실패" }); } const { email, box } = result; if (box === target) { await safeRollback(t); return res.json({ success: true }); } // rspamd 학습 (스팸함 관련 이동 시) let learned = false; try { const rspamd = require("../services/rspamdService"); const rawEmail = email.rawEmail || rspamd.buildRawEmail(email.toJSON()); if (target === "SPAM" && box !== "SPAM") { // 스팸함으로 이동 → 스팸으로 학습 await rspamd.learnSpam(rawEmail); console.log(`[rspamd] 스팸 학습 완료: ${email.subject}`); learned = true; } else if (box === "SPAM" && target !== "SPAM") { // 스팸함에서 나감 → 정상 메일(ham)로 학습 await rspamd.learnHam(rawEmail); console.log(`[rspamd] 햄 학습 완료: ${email.subject}`); learned = true; } } catch (rspamdError) { console.warn("[rspamd] 학습 실패 (무시):", rspamdError.message); } const TargetModel = getModel(target); // 대상 메일함으로 복사 const emailData = email.toJSON(); delete emailData.id; emailData.originalMailbox = box; if (emailData.isRead === undefined) emailData.isRead = true; const newEmail = await TargetModel.create(emailData, { transaction: t }); await email.destroy({ transaction: t }); await t.commit(); res.json({ success: true, newId: newEmail.id, target, learned }); } catch (error) { await safeRollback(t); console.error("메일 이동 오류:", error); res.status(500).json({ error: "메일 이동 실패" }); } }); /** * 영구 삭제 * DELETE /api/emails/:id?mailbox=TRASH */ router.delete("/emails/:id", async (req, res) => { try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) return res.status(404).json({ error: "메일 찾기 실패" }); await result.email.destroy(); res.json({ success: true }); } catch (error) { console.error("삭제 오류:", error); res.status(500).json({ error: "삭제 실패" }); } }); /** * 첨부파일 다운로드 (스트리밍) * GET /api/emails/:id/attachments/:filename?mailbox=INBOX */ router.get("/emails/:id/attachments/:filename", async (req, res) => { try { const { mailbox } = req.query; const result = await findEmail(req.params.id, req.user.email, mailbox); if (!result) return res.status(404).json({ error: "메일 찾기 실패" }); // 첨부파일 목록 파싱 let attachments = result.email.attachments || []; if (typeof attachments === "string") { try { attachments = JSON.parse(attachments); } catch { attachments = []; } } // 요청된 파일명으로 검색 const att = attachments.find((a) => a.filename === req.params.filename); if (!att) return res.status(404).json({ error: "파일을 찾을 수 없음" }); // S3에서 스트리밍 다운로드 const { stream, contentType, contentLength } = await getObjectStream( att.key ); res.setHeader("Content-Type", contentType || "application/octet-stream"); if (contentLength) res.setHeader("Content-Length", contentLength); const encoded = encodeURIComponent(att.filename); res.setHeader( "Content-Disposition", `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}` ); stream.pipe(res); } catch (error) { console.error("첨부파일 다운로드 오류:", error); res.status(500).json({ error: "다운로드 오류" }); } }); /** * 메일 발송 (SES + 보낸편지함 저장) * POST /api/send */ router.post("/send", async (req, res) => { try { const { to, subject, html, text, attachments = [] } = req.body; const userEmail = req.user.email; // 사용자 이름 조회하여 From 헤더 구성 const currentUser = await User.findOne({ where: { email: userEmail } }); const fromHeader = currentUser?.name ? `"${currentUser.name}" <${userEmail}>` : userEmail; // 첨부파일을 Garage에 업로드 const { uploadAttachment } = require("../services/s3Service"); const attachmentsData = []; for (const att of attachments) { const buffer = Buffer.from(att.content, "base64"); const key = await uploadAttachment(buffer, att.filename, att.contentType); attachmentsData.push({ filename: att.filename, contentType: att.contentType, size: buffer.length, key: key, }); } // SES를 통한 발송 (From 헤더에 이름 포함) await sendEmail({ from: fromHeader, to, subject, html, text, attachments }); // 보낸편지함에 저장 (첨부파일 정보 포함) const sentEmailData = { from: userEmail, fromName: currentUser?.name || null, to: Array.isArray(to) ? to.join(",") : to, subject, html: html, text: text || (html ? html.replace(/<[^>]*>/g, "") : ""), attachments: attachmentsData, date: new Date(), isRead: true, }; sentEmailData.size = calculateEmailSize(sentEmailData); await Sent.create(sentEmailData); // 발송 로그 테이블에도 기록 (통계용 - 보낸편지함 삭제와 무관하게 유지) await SentLog.create({ from: userEmail, to: Array.isArray(to) ? to.join(",") : to, subject: subject || "(제목 없음)", success: true, sentAt: new Date(), }); res.json({ success: true }); } catch (error) { console.error("메일 발송 오류:", error); res.status(500).json({ error: "발송 실패" }); } }); /** * 임시저장 생성 * POST /api/drafts */ router.post("/drafts", async (req, res) => { try { const { to, subject, html, text } = req.body; const userEmail = req.user.email; const draft = await Draft.create({ from: userEmail, to: Array.isArray(to) ? to.join(",") : to || "", subject: subject || "(제목 없음)", html: html || "", text: text || (html ? html.replace(/<[^>]*>/g, "") : ""), date: new Date(), isRead: true, isDeleted: false, }); res.json({ success: true, id: draft.id }); } catch (error) { console.error("임시저장 오류:", error); res.status(500).json({ error: "임시저장 실패" }); } }); /** * 임시저장 삭제 * DELETE /api/drafts/:id */ router.delete("/drafts/:id", async (req, res) => { try { const draft = await Draft.findOne({ where: { id: req.params.id, from: req.user.email }, }); if (!draft) { return res.status(404).json({ error: "임시저장 찾기 실패" }); } await draft.destroy(); res.json({ success: true }); } catch (error) { console.error("임시저장 삭제 오류:", error); res.status(500).json({ error: "삭제 실패" }); } }); /** * 사용자 이메일-이름 매핑 조회 * GET /api/user-names * 로컬 가입 사용자의 이메일과 이름 매핑 반환 */ router.get("/user-names", authenticateToken, async (req, res) => { try { const users = await User.findAll({ attributes: ["email", "name"], where: { name: { [Op.ne]: null } }, }); // 이메일 -> 이름 매핑 객체로 변환 const userMap = {}; users.forEach((user) => { if (user.name) { userMap[user.email] = user.name; } }); res.json(userMap); } catch (error) { console.error("사용자 이름 조회 오류:", error); res.status(500).json({ error: "조회 실패" }); } }); module.exports = router;