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

1089 lines
32 KiB
JavaScript

/**
* 메일 라우터
* 메일 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;