commit c1da47f057e102f72f0aee870ff24130ee599497 Author: caadiq Date: Tue Dec 16 08:18:15 2025 +0900 Initial commit: mailbox diff --git a/.env b/.env new file mode 100644 index 0000000..bb4128e --- /dev/null +++ b/.env @@ -0,0 +1,16 @@ +# Database +DB_HOST=mariadb +DB_USER=mail +DB_PASSWORD=Bv4@nXk6!pMy9^Gt +DB_NAME=mail + +# App Config +JWT_SECRET=2scRswv/9fgsYLpsZ823VDWTVHvzRZ7NLjVxnL8o/vs= +TZ=Asia/Seoul + +# S3 (RustFS) 설정 +MINIO_ENDPOINT=http://rustfs:9000 +MINIO_ACCESS_KEY=M2xkrH1f75mFWKIpNzYw +MINIO_SECRET_KEY=lR37iDTXNBkQrjSmbf4v1cgxFJh658VWnYMIzE0s +MINIO_BUCKET=emails +AWS_REGION=us-east-1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9a0b57d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] diff --git a/backend/config/database.js b/backend/config/database.js new file mode 100644 index 0000000..016ba31 --- /dev/null +++ b/backend/config/database.js @@ -0,0 +1,31 @@ +const { Sequelize } = require("sequelize"); +require("dotenv").config(); + +const sequelize = new Sequelize( + process.env.DB_NAME || "mail", + process.env.DB_USER || "root", + process.env.DB_PASSWORD || "password", + { + host: process.env.DB_HOST || "mariadb", + dialect: "mysql", + timezone: "+09:00", + logging: false, + pool: { + max: 10, + min: 0, + acquire: 60000, + idle: 10000, + }, + // 연결 재시도 설정 + retry: { + max: 3, + timeout: 3000, + }, + // MariaDB 연결 유지 옵션 + dialectOptions: { + connectTimeout: 60000, + }, + } +); + +module.exports = sequelize; diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..3677fa6 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,51 @@ +/** + * 이메일 백엔드 서버 진입점 + * Express HTTP 서버 + SMTP 서버 시작 + */ +const express = require("express"); +const cors = require("cors"); +const dotenv = require("dotenv"); + +// 환경 변수 로드 +dotenv.config(); + +const app = express(); +const port = process.env.PORT || 3000; + +// 미들웨어 설정 +app.use(cors()); +app.use(express.json({ limit: "50mb" })); // Base64 인코딩된 첨부파일 40MB 지원 + +// 헬스 체크 엔드포인트 +app.get("/", (req, res) => { + res.send("Email Client Backend is running"); +}); + +// 라우터 등록 +const authRoutes = require("./routes/auth"); +const mailRoutes = require("./routes/mail"); +const adminRoutes = require("./routes/admin"); + +app.use("/api", authRoutes); +app.use("/api/admin", adminRoutes); +app.use("/api", mailRoutes); + +// 서비스 및 유틸리티 +const { startSMTPServer } = require("./services/smtpService"); +const initializeDatabase = require("./utils/dbInit"); + +/** + * 서버 시작 + * 1. SMTP 서버 시작 (포트 25) + * 2. 데이터베이스 초기화 + * 3. HTTP 서버 시작 + */ +const startServer = async () => { + startSMTPServer(); + await initializeDatabase(); + app.listen(port, () => { + console.log(`[서버] HTTP 서버 시작: http://localhost:${port}`); + }); +}; + +startServer(); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..49f079e --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,33 @@ +/** + * JWT 인증 미들웨어 + * Authorization 헤더에서 Bearer 토큰을 추출하여 검증 + */ +const jwt = require("jsonwebtoken"); + +const JWT_SECRET = + process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production"; + +/** + * JWT 토큰 인증 미들웨어 + * @param {Request} req - Express 요청 객체 + * @param {Response} res - Express 응답 객체 + * @param {NextFunction} next - 다음 미들웨어 + */ +const authenticateToken = (req, res, next) => { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.split(" ")[1]; + + if (!token) { + return res.status(401).json({ error: "인증 토큰이 필요합니다" }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ error: "유효하지 않은 토큰입니다" }); + } + req.user = user; + next(); + }); +}; + +module.exports = { authenticateToken }; diff --git a/backend/models/Draft.js b/backend/models/Draft.js new file mode 100644 index 0000000..ac3ede5 --- /dev/null +++ b/backend/models/Draft.js @@ -0,0 +1,29 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Draft = sequelize.define( + "Draft", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: true }, // 임시저장은 받는 사람 없을 수 있음 + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: true }, + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }, + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + }, + { + tableName: "drafts", + timestamps: true, + } +); + +module.exports = Draft; diff --git a/backend/models/EmailTranslation.js b/backend/models/EmailTranslation.js new file mode 100644 index 0000000..1c0efc4 --- /dev/null +++ b/backend/models/EmailTranslation.js @@ -0,0 +1,60 @@ +/** + * 이메일 번역 캐시 테이블 + * 번역된 이메일 내용 저장 + */ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const EmailTranslation = sequelize.define( + "EmailTranslation", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + emailId: { + type: DataTypes.INTEGER, + allowNull: false, + field: "email_id", + comment: "원본 이메일 ID", + }, + mailbox: { + type: DataTypes.STRING(20), + allowNull: false, + comment: "메일함 (inbox, sent, trash 등)", + }, + targetLang: { + type: DataTypes.STRING(10), + allowNull: false, + field: "target_lang", + comment: "번역 대상 언어", + }, + translatedContent: { + type: DataTypes.TEXT("long"), + allowNull: false, + field: "translated_content", + comment: "번역된 내용", + }, + modelUsed: { + type: DataTypes.STRING(50), + allowNull: true, + field: "model_used", + comment: "사용된 AI 모델", + }, + }, + { + tableName: "email_translations", + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + indexes: [ + { + unique: true, + fields: ["email_id", "mailbox", "target_lang"], + }, + ], + } +); + +module.exports = EmailTranslation; diff --git a/backend/models/GeminiModel.js b/backend/models/GeminiModel.js new file mode 100644 index 0000000..d8ccd9f --- /dev/null +++ b/backend/models/GeminiModel.js @@ -0,0 +1,77 @@ +/** + * Gemini 모델 테이블 + * 사용 가능한 AI 모델 목록 저장 + */ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const GeminiModel = sequelize.define( + "GeminiModel", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + modelId: { + type: DataTypes.STRING(100), + allowNull: false, + unique: true, + field: "model_id", + comment: "모델 ID (예: gemini-2.5-flash)", + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "모델 표시명", + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + comment: "모델 설명", + }, + sortOrder: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: "sort_order", + comment: "정렬 순서", + }, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: "is_active", + comment: "활성화 여부", + }, + }, + { + tableName: "gemini_models", + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + } +); + +// 기본 모델 데이터 시드 +GeminiModel.seedDefaultModels = async () => { + const defaults = [ + { modelId: "gemini-3-pro-preview", name: "Gemini 3 Pro", sortOrder: 1 }, + { modelId: "gemini-2.5-pro", name: "Gemini 2.5 Pro", sortOrder: 2 }, + { modelId: "gemini-2.5-flash", name: "Gemini 2.5 Flash", sortOrder: 3 }, + { + modelId: "gemini-2.5-flash-lite", + name: "Gemini 2.5 Flash-Lite", + sortOrder: 4, + }, + ]; + + for (const model of defaults) { + await GeminiModel.findOrCreate({ + where: { modelId: model.modelId }, + defaults: model, + }); + } +}; + +module.exports = GeminiModel; diff --git a/backend/models/Important.js b/backend/models/Important.js new file mode 100644 index 0000000..97fc994 --- /dev/null +++ b/backend/models/Important.js @@ -0,0 +1,30 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Important = sequelize.define( + "Important", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: false }, + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: false }, + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }, + originalMailbox: { type: DataTypes.STRING, allowNull: true }, // 중요 편지함은 참조 개념일 수도 있으나 우선 독립 테이블로 구성 + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + }, + { + tableName: "important", + timestamps: true, + } +); + +module.exports = Important; diff --git a/backend/models/Inbox.js b/backend/models/Inbox.js new file mode 100644 index 0000000..9d7759a --- /dev/null +++ b/backend/models/Inbox.js @@ -0,0 +1,32 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Inbox = sequelize.define( + "Inbox", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: false }, + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: false }, + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }, + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + // rspamd 스팸 필터 관련 필드 + spamScore: { type: DataTypes.FLOAT, allowNull: true }, + rawEmail: { type: DataTypes.TEXT("long"), allowNull: true }, + }, + { + tableName: "inbox", + timestamps: true, + } +); + +module.exports = Inbox; diff --git a/backend/models/Sent.js b/backend/models/Sent.js new file mode 100644 index 0000000..6002d3b --- /dev/null +++ b/backend/models/Sent.js @@ -0,0 +1,29 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Sent = sequelize.define( + "Sent", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: false }, + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: true }, // 보낸 메일은 기본 읽음 + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }, + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + }, + { + tableName: "sent", + timestamps: true, + } +); + +module.exports = Sent; diff --git a/backend/models/SentLog.js b/backend/models/SentLog.js new file mode 100644 index 0000000..3055bdf --- /dev/null +++ b/backend/models/SentLog.js @@ -0,0 +1,48 @@ +/** + * 발송 로그 모델 + * 메일 발송 이력을 기록 (보낸편지함에서 삭제해도 통계 유지) + */ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const SentLog = sequelize.define( + "SentLog", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + from: { + type: DataTypes.STRING, + allowNull: false, + comment: "발신자 이메일", + }, + to: { + type: DataTypes.STRING, + allowNull: false, + comment: "수신자 이메일", + }, + subject: { + type: DataTypes.STRING, + allowNull: true, + comment: "메일 제목", + }, + success: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: "발송 성공 여부", + }, + sentAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + comment: "발송 시간", + }, + }, + { + tableName: "sent_logs", + timestamps: false, + } +); + +module.exports = SentLog; diff --git a/backend/models/SmtpLog.js b/backend/models/SmtpLog.js new file mode 100644 index 0000000..ee46d13 --- /dev/null +++ b/backend/models/SmtpLog.js @@ -0,0 +1,73 @@ +/** + * SMTP 접속 로그 모델 + * 25포트로 접근한 클라이언트 IP와 국가 정보를 기록 + */ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const SmtpLog = sequelize.define( + "SmtpLog", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + remoteAddress: { + type: DataTypes.STRING, + allowNull: false, + comment: "접속한 클라이언트 IP 주소", + }, + country: { + type: DataTypes.STRING(10), + allowNull: true, + comment: "국가 코드 (예: KR, US)", + }, + countryName: { + type: DataTypes.STRING(100), + allowNull: true, + comment: "국가 이름 (예: South Korea)", + }, + hostname: { + type: DataTypes.STRING, + allowNull: true, + comment: "클라이언트 호스트명 (있는 경우)", + }, + mailFrom: { + type: DataTypes.STRING, + allowNull: true, + comment: "발신자 이메일", + }, + rcptTo: { + type: DataTypes.STRING, + allowNull: true, + comment: "수신자 이메일", + }, + success: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: "메일 수신 성공 여부", + }, + isSpam: { + type: DataTypes.BOOLEAN, + defaultValue: false, + comment: "스팸 여부", + }, + spamScore: { + type: DataTypes.FLOAT, + allowNull: true, + comment: "스팸 점수", + }, + connectedAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + comment: "접속 시간", + }, + }, + { + tableName: "smtp_logs", + timestamps: false, + } +); + +module.exports = SmtpLog; diff --git a/backend/models/Spam.js b/backend/models/Spam.js new file mode 100644 index 0000000..2e724c6 --- /dev/null +++ b/backend/models/Spam.js @@ -0,0 +1,33 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Spam = sequelize.define( + "Spam", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: false }, + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: false }, + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false }, + originalMailbox: { type: DataTypes.STRING, allowNull: true }, + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + // rspamd 스팸 필터 관련 필드 + spamScore: { type: DataTypes.FLOAT, allowNull: true }, + rawEmail: { type: DataTypes.TEXT("long"), allowNull: true }, + }, + { + tableName: "spam", + timestamps: true, + } +); + +module.exports = Spam; diff --git a/backend/models/SystemConfig.js b/backend/models/SystemConfig.js new file mode 100644 index 0000000..df29ac7 --- /dev/null +++ b/backend/models/SystemConfig.js @@ -0,0 +1,41 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const SystemConfig = sequelize.define( + "SystemConfig", + { + key: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true, + }, + value: { + type: DataTypes.TEXT, // Using TEXT to store JSON or long strings + allowNull: true, + get() { + // Automatically parse JSON if possible + const rawValue = this.getDataValue("value"); + try { + return JSON.parse(rawValue); + } catch (e) { + return rawValue; + } + }, + set(value) { + // Automatically stringify objects + if (typeof value === "object" && value !== null) { + this.setDataValue("value", JSON.stringify(value)); + } else { + this.setDataValue("value", value); + } + }, + }, + }, + { + tableName: "system_configs", + timestamps: false, // No need for created/updatedAt for config + } +); + +module.exports = SystemConfig; diff --git a/backend/models/Trash.js b/backend/models/Trash.js new file mode 100644 index 0000000..49d42ec --- /dev/null +++ b/backend/models/Trash.js @@ -0,0 +1,30 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const Trash = sequelize.define( + "Trash", + { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + messageId: { type: DataTypes.STRING, unique: false, allowNull: true }, + from: { type: DataTypes.STRING, allowNull: false }, + fromName: { type: DataTypes.STRING, allowNull: true }, + to: { type: DataTypes.TEXT, allowNull: false }, + subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" }, + text: { type: DataTypes.TEXT("long"), allowNull: true }, + html: { type: DataTypes.TEXT("long"), allowNull: true }, + attachments: { type: DataTypes.JSON, defaultValue: [] }, + date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + flags: { type: DataTypes.JSON, defaultValue: [] }, + isRead: { type: DataTypes.BOOLEAN, defaultValue: false }, + isDeleted: { type: DataTypes.BOOLEAN, defaultValue: true }, // 항상 true + originalMailbox: { type: DataTypes.STRING, allowNull: true }, // 복구를 위한 원래 위치 저장 + // 메일 크기 (bytes) - 검색 필터용 + size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 }, + }, + { + tableName: "trash", + timestamps: true, + } +); + +module.exports = Trash; diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000..ec44426 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,42 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const User = sequelize.define( + "User", + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + isEmail: true, + }, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + name: { + type: DataTypes.STRING, + allowNull: true, + }, + isAdmin: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + createdAt: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + }, + { + tableName: "users", + } +); + +module.exports = User; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..f3f8f8a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,25 @@ +{ + "name": "email-backend", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.701.0", + "@aws-sdk/s3-request-presigner": "^3.701.0", + "@google/genai": "^1.0.0", + "bcryptjs": "^3.0.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "imap-simple": "^5.1.0", + "jsonwebtoken": "^9.0.3", + "mailparser": "^3.7.1", + "mysql2": "^3.11.5", + "nodemailer": "^6.9.16", + "resend": "^6.6.0", + "sequelize": "^6.37.5", + "smtp-server": "^3.12.0" + } +} \ No newline at end of file diff --git a/backend/routes/admin.js b/backend/routes/admin.js new file mode 100644 index 0000000..25256b2 --- /dev/null +++ b/backend/routes/admin.js @@ -0,0 +1,746 @@ +/** + * 관리자 라우터 + * 통계 대시보드, 사용자 관리, SES 설정 등 + */ +const express = require("express"); +const router = express.Router(); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); +const { Op, fn, col } = require("sequelize"); +const User = require("../models/User"); +const Inbox = require("../models/Inbox"); +const Sent = require("../models/Sent"); +const SystemConfig = require("../models/SystemConfig"); +const SmtpLog = require("../models/SmtpLog"); +const SentLog = require("../models/SentLog"); +const Spam = require("../models/Spam"); +const { sendEmail } = require("../services/emailService"); +const rspamd = require("../services/rspamdService"); + +// 공통 헬퍼 함수 import +const { + calculateStorageSize, + getPeriodStartDate, +} = require("../utils/helpers"); + +const JWT_SECRET = + process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production"; + +// ============================================================================ +// 미들웨어 +// ============================================================================ + +/** + * 관리자 권한 검증 미들웨어 + * JWT 토큰 검증 후 DB에서 실시간 권한 확인 + * (권한이 박탈된 경우 즉시 차단) + */ +const verifyAdmin = async (req, res, next) => { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ error: "토큰이 없습니다" }); + + try { + const decoded = jwt.verify(token, JWT_SECRET); + + // DB에서 최신 사용자 정보 조회 + const user = await User.findByPk(decoded.id); + if (!user) { + return res.status(403).json({ error: "사용자를 찾을 수 없습니다" }); + } + + // 실시간 관리자 권한 확인 + if (!user.isAdmin) { + return res.status(403).json({ error: "관리자 권한이 필요합니다" }); + } + + req.user = { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + }; + next(); + } catch (err) { + return res.status(403).json({ error: "유효하지 않은 토큰" }); + } +}; + +// 최고 관리자 계정 (삭제 불가, 비밀번호만 수정 가능) +const SUPER_ADMIN_EMAIL = "admin@caadiq.co.kr"; + +// 모든 라우트에 관리자 검증 적용 +router.use(verifyAdmin); + +// ============================================================================ +// API 라우트 - 통계 대시보드 +// ============================================================================ + +/** + * 대시보드 통계 조회 + * GET /api/admin/stats + */ +router.get("/stats", async (req, res) => { + try { + const userCount = await User.count(); + + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + // 오늘 및 총 발송/수신 메일 수 + const [ + sentToday, + receivedToday, + spamToday, + totalSpam, + totalSent, + totalReceived, + ] = await Promise.all([ + SentLog.count({ + where: { sentAt: { [Op.gte]: startOfDay }, success: true }, + }), + SmtpLog.count({ + where: { connectedAt: { [Op.gte]: startOfDay }, success: true }, + }), + SmtpLog.count({ + where: { connectedAt: { [Op.gte]: startOfDay }, isSpam: true }, + }), + Spam.count(), + Sent.count(), // 보낸편지함 실제 메일 수 + Inbox.count(), // 받은편지함 실제 메일 수 + ]); + + // 스토리지 사용량 계산 + const [inboxItems, sentItems] = await Promise.all([ + Inbox.findAll({ + attributes: ["attachments", "subject", "date", "text", "html"], + }), + Sent.findAll({ + attributes: ["attachments", "subject", "date", "text", "html"], + }), + ]); + + const totalSize = + calculateStorageSize(inboxItems) + calculateStorageSize(sentItems); + const storageUsed = (totalSize / (1024 * 1024)).toFixed(2); + + // 스토리지 쿼터 설정 (기본값 50GB) + const quotaConfig = await SystemConfig.findOne({ + where: { key: "user_storage_quota" }, + }); + const storageLimit = quotaConfig ? parseInt(quotaConfig.value) : 51200; + + // 최근 활동 로그 생성 + const logs = []; + + // 최근 수신 메일 (5개) + const recentInbox = [...inboxItems] + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .slice(0, 5); + recentInbox.forEach((item) => { + logs.push({ + type: "INBOX", + date: new Date(item.date), + message: `메일 수신: ${item.subject}`, + detail: item.subject, + }); + }); + + // 최근 발송 메일 (5개) + const recentSent = [...sentItems] + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .slice(0, 5); + recentSent.forEach((item) => { + logs.push({ + type: "SENT", + date: new Date(item.date), + message: `메일 발송: ${item.subject}`, + detail: item.subject, + }); + }); + + // 최근 가입 사용자 (5개) + const recentUsers = await User.findAll({ + limit: 5, + order: [["createdAt", "DESC"]], + attributes: ["email", "createdAt"], + }); + recentUsers.forEach((user) => { + if (user.createdAt) { + logs.push({ + type: "USER", + date: new Date(user.createdAt), + message: `사용자 가입: ${user.email}`, + detail: user.email, + }); + } + }); + + // 로그 병합 후 최신순 정렬 + logs.sort((a, b) => b.date - a.date); + const recentLogs = logs.slice(0, 5); + + // 차트 데이터 (최근 7일) - sent_logs/smtp_logs 기반 + const chartData = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + const dateStr = d.toISOString().split("T")[0]; + const dayStart = new Date(d.setHours(0, 0, 0, 0)); + const dayEnd = new Date(d.setHours(23, 59, 59, 999)); + + const [sent, received] = await Promise.all([ + SentLog.count({ + where: { + sentAt: { [Op.between]: [dayStart, dayEnd] }, + success: true, + }, + }), + SmtpLog.count({ + where: { + connectedAt: { [Op.between]: [dayStart, dayEnd] }, + success: true, + }, + }), + ]); + + chartData.push({ name: dateStr.slice(5), sent, received }); + } + + // 시스템 상태 체크 + let dbStatus = "Connected"; + try { + await User.findOne(); + } catch { + dbStatus = "Disconnected"; + } + + // rspamd 상태 체크 + const rspamdStatus = (await rspamd.checkHealth()) ? "Running" : "Stopped"; + + // 기간 파라미터 처리 (1d, 7d, 30d, all) + const period = req.query.period || "all"; + const periodStart = getPeriodStartDate(period); + + // 유저별 메일 통계 (Top Users) - sent_logs/smtp_logs 기반 + const allUsers = await User.findAll({ attributes: ["email"] }); + const topUsersData = await Promise.all( + allUsers.map(async (user) => { + const sentWhere = { from: user.email, success: true }; + const receivedWhere = { + rcptTo: { [Op.like]: `%${user.email}%` }, + success: true, + }; + + if (periodStart) { + sentWhere.sentAt = { [Op.gte]: periodStart }; + receivedWhere.connectedAt = { [Op.gte]: periodStart }; + } + + const [sentCount, receivedCount] = await Promise.all([ + SentLog.count({ where: sentWhere }), + SmtpLog.count({ where: receivedWhere }), + ]); + return { + email: user.email, + sent: sentCount, + received: receivedCount, + total: sentCount + receivedCount, + }; + }) + ); + const topUsers = topUsersData.sort((a, b) => b.total - a.total).slice(0, 5); + + // SMTP 접속 IP 통계 (Remote IPs) - 국가 정보 포함 + const smtpLogWhere = periodStart + ? { connectedAt: { [Op.gte]: periodStart } } + : {}; + const remoteIpsData = await SmtpLog.findAll({ + attributes: [ + "remoteAddress", + "country", + "countryName", + "hostname", + [fn("COUNT", col("id")), "count"], + ], + where: smtpLogWhere, + group: ["remoteAddress", "country", "countryName", "hostname"], + order: [[fn("COUNT", col("id")), "DESC"]], + limit: 10, + raw: true, + }); + const remoteIps = remoteIpsData.map((item) => ({ + ip: item.remoteAddress, + country: item.country, + countryName: item.countryName, + hostname: item.hostname, + count: parseInt(item.count), + })); + + res.json({ + userCount, + sentToday, + receivedToday, + spamToday, + totalSpam, + totalSent, + totalReceived, + storageUsed: storageUsed + " MB", + storageLimit, + chartData, + recentLogs, + topUsers, + remoteIps, + rspamdStatus, + dbStatus, + }); + } catch (error) { + console.error("통계 조회 오류:", error); + res.status(500).json({ error: "통계 조회 실패" }); + } +}); + +/** + * 접속 IP 목록 페이징 조회 (개별 로그) + * GET /api/admin/remote-ips?page=1&limit=20&period=all + */ +router.get("/remote-ips", async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개 + const period = req.query.period || "all"; + const maxItems = 50; // 최대 50개까지만 표시 + const offset = (page - 1) * limit; + + // 기간 필터 + const periodStart = getPeriodStartDate(period); + + const smtpLogWhere = periodStart + ? { connectedAt: { [Op.gte]: periodStart } } + : {}; + + // 전체 개수 조회 + const totalCount = await SmtpLog.count({ where: smtpLogWhere }); + + // 페이징된 개별 로그 조회 + const remoteIpsData = await SmtpLog.findAll({ + attributes: [ + "remoteAddress", + "country", + "countryName", + "hostname", + "mailFrom", + "rcptTo", + "connectedAt", + ], + where: smtpLogWhere, + order: [["connectedAt", "DESC"]], + limit, + offset, + raw: true, + }); + + const remoteIps = remoteIpsData.map((item) => ({ + ip: item.remoteAddress, + country: item.country, + countryName: item.countryName, + hostname: item.hostname, + mailFrom: item.mailFrom, + rcptTo: item.rcptTo, + connectedAt: item.connectedAt, + })); + + // 최대 50개로 제한된 전체 개수 + const effectiveTotal = Math.min(totalCount, maxItems); + + res.json({ + data: remoteIps, + pagination: { + page, + limit, + total: effectiveTotal, + hasMore: offset + remoteIps.length < effectiveTotal, + }, + }); + } catch (error) { + console.error("접속 IP 조회 오류:", error); + res.status(500).json({ error: "접속 IP 조회 실패" }); + } +}); + +/** + * 최근 활동 로그 페이징 조회 + * GET /api/admin/recent-logs?page=1&limit=20 + */ +router.get("/recent-logs", async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개 + const maxItems = 50; // 최대 50개까지만 표시 + const offset = (page - 1) * limit; + + const logs = []; + + // 최근 수신 메일 + const recentInbox = await Inbox.findAll({ + order: [["date", "DESC"]], + attributes: ["from", "subject", "date"], + }); + recentInbox.forEach((item) => { + logs.push({ + type: "INBOX", + date: new Date(item.date), + message: `메일 수신: ${item.subject}`, + detail: item.from, + }); + }); + + // 최근 발송 메일 + const recentSent = await Sent.findAll({ + order: [["date", "DESC"]], + attributes: ["to", "subject", "date"], + }); + recentSent.forEach((item) => { + logs.push({ + type: "SENT", + date: new Date(item.date), + message: `메일 발송: ${item.subject}`, + detail: item.to, + }); + }); + + // 최근 가입 사용자 + const recentUsers = await User.findAll({ + order: [["createdAt", "DESC"]], + attributes: ["email", "createdAt"], + }); + recentUsers.forEach((user) => { + if (user.createdAt) { + logs.push({ + type: "USER", + date: new Date(user.createdAt), + message: `사용자 가입: ${user.email}`, + detail: user.email, + }); + } + }); + + // 날짜순 정렬 후 페이징 (최대 50개) + logs.sort((a, b) => b.date - a.date); + const effectiveTotal = Math.min(logs.length, maxItems); + const paginatedLogs = logs.slice( + offset, + Math.min(offset + limit, effectiveTotal) + ); + + res.json({ + data: paginatedLogs, + pagination: { + page, + limit, + total: effectiveTotal, + hasMore: offset + paginatedLogs.length < effectiveTotal, + }, + }); + } catch (error) { + console.error("최근 활동 조회 오류:", error); + res.status(500).json({ error: "최근 활동 조회 실패" }); + } +}); + +// ============================================================================ +// API 라우트 - 사용자 관리 +// ============================================================================ + +/** + * 사용자 목록 조회 + * GET /api/admin/users + */ +router.get("/users", async (req, res) => { + try { + const users = await User.findAll({ attributes: { exclude: ["password"] } }); + res.json(users); + } catch (error) { + console.error("사용자 목록 조회 오류:", error); + res.status(500).json({ error: "사용자 목록 조회 실패" }); + } +}); + +/** + * 사용자 생성 + * POST /api/admin/users + */ +router.post("/users", async (req, res) => { + try { + const { email, password, name, isAdmin } = req.body; + + // 중복 이메일 체크 + const existing = await User.findOne({ where: { email } }); + if (existing) + return res.status(400).json({ error: "이미 사용 중인 이메일입니다." }); + + const hashedPassword = await bcrypt.hash(password, 10); + const user = await User.create({ + email, + password: hashedPassword, + name, + isAdmin, + }); + + res.json({ success: true, user: { id: user.id, email: user.email } }); + } catch (error) { + console.error("사용자 생성 오류:", error); + res.status(500).json({ error: "사용자 생성 실패" }); + } +}); + +/** + * 사용자 수정 + * PUT /api/admin/users/:id + */ +router.put("/users/:id", async (req, res) => { + try { + const { password, name, isAdmin } = req.body; + const user = await User.findByPk(req.params.id); + + if (!user) { + return res.status(404).json({ error: "사용자를 찾을 수 없습니다" }); + } + + // 최고 관리자는 비밀번호만 수정 가능 + if (user.email === SUPER_ADMIN_EMAIL) { + if (password) { + user.password = await bcrypt.hash(password, 10); + await user.save(); + } + return res.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name, + isAdmin: user.isAdmin, + }, + }); + } + + if (name !== undefined) user.name = name; + if (isAdmin !== undefined) user.isAdmin = isAdmin; + if (password) { + user.password = await bcrypt.hash(password, 10); + } + + await user.save(); + + res.json({ + success: true, + user: { + id: user.id, + email: user.email, + name: user.name, + isAdmin: user.isAdmin, + }, + }); + } catch (error) { + console.error("사용자 수정 오류:", error); + res.status(500).json({ error: "사용자 수정 실패" }); + } +}); + +/** + * 사용자 삭제 + * DELETE /api/admin/users/:id + */ +router.delete("/users/:id", async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + + // 최고 관리자 삭제 금지 + if (user && user.email === SUPER_ADMIN_EMAIL) { + return res + .status(403) + .json({ error: "최고 관리자 계정은 삭제할 수 없습니다" }); + } + + await User.destroy({ where: { id: req.params.id } }); + res.json({ success: true }); + } catch (error) { + console.error("사용자 삭제 오류:", error); + res.status(500).json({ error: "사용자 삭제 실패" }); + } +}); + +// ============================================================================ +// API 라우트 - Resend 설정 +// ============================================================================ + +/** + * Resend 설정 조회 + * GET /api/admin/config/email + */ +router.get("/config/email", async (req, res) => { + try { + const configs = await SystemConfig.findAll({ + where: { + key: { + [Op.in]: [ + "resend_api_key", + "mail_from", + "user_storage_quota", + "session_expire_hours", + ], + }, + }, + }); + + const configMap = {}; + configs.forEach((c) => (configMap[c.key] = c.value)); + + res.json(configMap); + } catch (error) { + console.error("이메일 설정 조회 오류:", error); + res.status(500).json({ error: "설정 조회 실패" }); + } +}); + +/** + * Resend 설정 저장 + * POST /api/admin/config/email + */ +router.post("/config/email", async (req, res) => { + try { + const { + resend_api_key, + mail_from, + user_storage_quota, + session_expire_hours, + } = req.body; + + // 각 설정값 upsert (있으면 업데이트, 없으면 생성) + if (resend_api_key && !resend_api_key.includes("*")) + await SystemConfig.upsert({ + key: "resend_api_key", + value: resend_api_key, + }); + if (mail_from) + await SystemConfig.upsert({ key: "mail_from", value: mail_from }); + if (user_storage_quota) + await SystemConfig.upsert({ + key: "user_storage_quota", + value: user_storage_quota, + }); + if (session_expire_hours) + await SystemConfig.upsert({ + key: "session_expire_hours", + value: session_expire_hours, + }); + + res.json({ success: true }); + } catch (error) { + console.error("이메일 설정 저장 오류:", error); + res.status(500).json({ error: "설정 저장 실패" }); + } +}); + +/** + * Resend 연결 테스트 (테스트 이메일 발송) + * POST /api/admin/config/email/test + */ +router.post("/config/email/test", async (req, res) => { + try { + const { to, resend_api_key, mail_from } = req.body; + + if (!resend_api_key) { + return res.status(400).json({ error: "Resend API 키가 필요합니다" }); + } + + // 입력된 값으로 테스트하기 위해 직접 Resend 클라이언트 생성 + const { Resend } = require("resend"); + const resend = new Resend(resend_api_key); + + const result = await resend.emails.send({ + from: mail_from || to, + to: [to], + subject: "Resend 연결 테스트", + html: "

연결 성공!

Resend 이메일 설정이 정상적으로 작동합니다.

", + text: "연결 성공! Resend 이메일 설정이 정상적으로 작동합니다.", + }); + + if (result.error) { + throw new Error(result.error.message); + } + + res.json({ success: true }); + } catch (error) { + console.error("Resend 테스트 오류:", error); + res.status(500).json({ error: error.message }); + } +}); + +// ============================================================================ +// API 라우트 - Gemini 설정 +// ============================================================================ + +const geminiService = require("../services/geminiService"); + +/** + * Gemini 설정 조회 + * GET /api/admin/config/gemini + */ +router.get("/config/gemini", async (req, res) => { + try { + const config = await geminiService.getGeminiConfig(); + res.json(config); + } catch (error) { + console.error("Gemini 설정 조회 오류:", error); + res.status(500).json({ error: "Gemini 설정 조회 실패" }); + } +}); + +/** + * Gemini 설정 저장 + * POST /api/admin/config/gemini + */ +router.post("/config/gemini", async (req, res) => { + try { + const { gemini_api_key, gemini_model } = req.body; + await geminiService.saveGeminiConfig({ gemini_api_key, gemini_model }); + res.json({ success: true }); + } catch (error) { + console.error("Gemini 설정 저장 오류:", error); + res.status(500).json({ error: "Gemini 설정 저장 실패" }); + } +}); + +/** + * Gemini 번역 API (일반 사용자도 사용 가능) + * POST /api/admin/translate + */ +router.post("/translate", async (req, res) => { + try { + const { text, targetLang } = req.body; + + if (!text) { + return res.status(400).json({ error: "번역할 텍스트가 필요합니다" }); + } + + const config = await geminiService.getGeminiConfig(); + if (!config.gemini_api_key) { + return res + .status(400) + .json({ error: "Gemini API 키가 설정되지 않았습니다" }); + } + + const translatedText = await geminiService.translateText( + text, + targetLang || "ko", + config.gemini_api_key, + config.gemini_model + ); + + res.json({ translatedText }); + } catch (error) { + console.error("번역 오류:", error); + res.status(500).json({ error: error.message || "번역 실패" }); + } +}); + +module.exports = router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..a078a89 --- /dev/null +++ b/backend/routes/auth.js @@ -0,0 +1,111 @@ +/** + * 인증 라우터 + * 로그인 및 토큰 검증 + */ +const express = require("express"); +const router = express.Router(); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcryptjs"); +const { Op } = require("sequelize"); +const User = require("../models/User"); +const SystemConfig = require("../models/SystemConfig"); + +const JWT_SECRET = + process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production"; + +/** + * 로그인 + * POST /api/login + * - 이메일 또는 아이디(@ 없이)로 로그인 가능 + */ +router.post("/login", async (req, res) => { + let { email, password } = req.body; + try { + // 아이디로 로그인 시도 시 (@ 없이 입력) 전체 이메일 조회 + if (!email.includes("@")) { + const user = await User.findOne({ + where: { email: { [Op.like]: `${email}@%` } }, + }); + if (user) email = user.email; + } + + const user = await User.findOne({ where: { email } }); + if (!user) { + return res.status(401).json({ error: "잘못된 자격 증명입니다." }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ error: "잘못된 자격 증명입니다." }); + } + + // DB에서 세션 만료 시간 조회 (기본값: 24시간) + let expireHours = 24; + try { + const config = await SystemConfig.findByPk("session_expire_hours"); + if (config && config.value) { + expireHours = parseInt(config.value) || 24; + } + } catch (e) { + console.error("세션 설정 조회 오류:", e); + } + + // JWT 토큰 생성 + console.log(`[로그인] ${user.email} - 세션 만료 시간: ${expireHours}시간`); + const token = jwt.sign( + { id: user.id, email: user.email, isAdmin: user.isAdmin }, + JWT_SECRET, + { expiresIn: `${expireHours}h` } + ); + + res.json({ + success: true, + token, + user: { + id: user.id, + email: user.email, + name: user.name, + isAdmin: user.isAdmin, + }, + }); + } catch (error) { + console.error("로그인 오류:", error); + res.status(500).json({ error: "서버 내부 오류" }); + } +}); + +/** + * 토큰 검증 및 최신 사용자 정보 조회 + * GET /api/verify + * - 토큰 검증 후 DB에서 최신 사용자 정보 가져옴 + */ +router.get("/verify", async (req, res) => { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ valid: false }); + + try { + const decoded = jwt.verify(token, JWT_SECRET); + + // DB에서 최신 사용자 정보 조회 + const user = await User.findByPk(decoded.id); + if (!user) { + return res + .status(401) + .json({ valid: false, error: "사용자를 찾을 수 없습니다" }); + } + + res.json({ + valid: true, + user: { + id: user.id, + email: user.email, + name: user.name, + isAdmin: user.isAdmin, + }, + }); + } catch (err) { + return res.status(401).json({ valid: false }); + } +}); + +module.exports = router; diff --git a/backend/routes/mail.js b/backend/routes/mail.js new file mode 100644 index 0000000..0008abb --- /dev/null +++ b/backend/routes/mail.js @@ -0,0 +1,1089 @@ +/** + * 메일 라우터 + * 메일 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; diff --git a/backend/services/emailService.js b/backend/services/emailService.js new file mode 100644 index 0000000..791aaeb --- /dev/null +++ b/backend/services/emailService.js @@ -0,0 +1,108 @@ +/** + * 이메일 발송 서비스 (Resend 사용) + * AWS SES 대신 Resend API를 사용하여 이메일 발송 + */ +const { Resend } = require("resend"); + +/** + * Resend 클라이언트 생성 + * DB에서 API 키를 가져와 설정 + * @returns {Promise<{resend: Resend, from: string}>} + */ +const createClient = async () => { + const SystemConfig = require("../models/SystemConfig"); + + // DB에서 설정 조회 + const configs = await SystemConfig.findAll(); + const configMap = {}; + configs.forEach((c) => (configMap[c.key] = c.value)); + + // DB 설정 우선, 없으면 환경 변수 사용 + const apiKey = configMap["resend_api_key"] || process.env.RESEND_API_KEY; + + if (!apiKey) { + throw new Error("Resend API 키가 설정되지 않았습니다"); + } + + const resend = new Resend(apiKey); + + return { + resend, + from: + configMap["mail_from"] || process.env.MAIL_FROM || "admin@caadiq.co.kr", + }; +}; + +/** + * 이메일 발송 (Resend 사용) + * @param {Object} params - 이메일 파라미터 + * @param {string} [params.from] - 발신자 (선택, 없으면 기본값 사용) + * @param {string} params.to - 수신자 + * @param {string} params.subject - 제목 + * @param {string} params.html - HTML 본문 + * @param {string} [params.text] - 텍스트 본문 (선택) + * @param {Array} [params.attachments] - 첨부파일 배열 (선택) + * @returns {Promise} Resend 발송 결과 + */ +exports.sendEmail = async ({ + from: senderEmail, + to, + subject, + html, + text, + attachments = [], +}) => { + try { + const { resend, from: defaultFrom } = await createClient(); + + // 발신자: 전달된 값 또는 기본값 사용 + const actualFrom = senderEmail || defaultFrom; + + // 첨부파일 변환 (Resend 형식: filename, content as Buffer/base64) + const resendAttachments = attachments.map((att) => ({ + filename: att.filename, + content: Buffer.isBuffer(att.content) + ? att.content + : Buffer.from(att.content, "base64"), + })); + + console.log(`[Resend] 첨부파일 개수: ${attachments.length}`); + if (attachments.length > 0) { + console.log( + `[Resend] 첨부파일 정보:`, + attachments.map((a) => ({ + filename: a.filename, + contentType: a.contentType, + contentLength: a.content?.length || 0, + })) + ); + } + + // to가 배열이면 그대로, 문자열이면 배열로 변환 + const toArray = Array.isArray(to) ? to : to.split(",").map((e) => e.trim()); + + // html과 text 기본값 처리 (둘 다 없으면 빈 공백이라도 전달) + const finalHtml = html || text || " "; + const finalText = text || (html ? html.replace(/<[^>]*>/g, "") : " "); + + const result = await resend.emails.send({ + from: actualFrom, + to: toArray, + subject: subject || "(제목 없음)", + html: finalHtml, + text: finalText, + attachments: resendAttachments.length > 0 ? resendAttachments : undefined, + }); + + if (result.error) { + console.error("[Resend] 발송 오류:", result.error); + throw new Error(result.error.message); + } + + console.log("[Resend] 메일 발송 완료:", result.data?.id); + return { messageId: result.data?.id }; + } catch (error) { + console.error("[Resend] 발송 오류:", error); + throw error; + } +}; diff --git a/backend/services/geminiService.js b/backend/services/geminiService.js new file mode 100644 index 0000000..25cff45 --- /dev/null +++ b/backend/services/geminiService.js @@ -0,0 +1,139 @@ +/** + * Gemini 번역 서비스 + * @google/genai SDK를 사용한 이메일 번역 + */ +const SystemConfig = require("../models/SystemConfig"); +const GeminiModel = require("../models/GeminiModel"); + +/** + * DB에서 활성화된 Gemini 모델 목록 조회 + */ +const listModels = async () => { + try { + const models = await GeminiModel.findAll({ + where: { isActive: true }, + order: [["sortOrder", "ASC"]], + attributes: ["modelId", "name", "description"], + }); + + return models.map((m) => ({ + id: m.modelId, + name: m.name, + description: m.description || "", + })); + } catch (error) { + console.error("Gemini 모델 목록 조회 오류:", error); + // 폴백: 기본 모델 반환 + return [ + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "" }, + ]; + } +}; + +/** + * Gemini API로 텍스트 번역 + * @param {string} text - 번역할 텍스트 + * @param {string} targetLang - 대상 언어 (ko, en, ja, zh 등) + * @param {string} apiKey - Gemini API 키 + * @param {string} model - 사용할 모델 ID + */ +const translateText = async (text, targetLang, apiKey, model) => { + if (!apiKey) { + throw new Error("Gemini API 키가 설정되지 않았습니다."); + } + + const langNames = { + ko: "한국어", + en: "영어", + ja: "일본어", + zh: "중국어", + es: "스페인어", + fr: "프랑스어", + de: "독일어", + ru: "러시아어", + pt: "포르투갈어", + it: "이탈리아어", + }; + + const targetLangName = langNames[targetLang] || targetLang; + const selectedModel = model || "gemini-2.5-flash"; + + // ES Module 동적 import + const { GoogleGenAI } = await import("@google/genai"); + + // Google GenAI SDK 초기화 + const ai = new GoogleGenAI({ apiKey }); + + // HTML 태그 보존하면서 번역하도록 프롬프트 작성 + const prompt = `다음 이메일 내용을 ${targetLangName}로 번역해주세요. HTML 태그가 있다면 그대로 유지하고 텍스트만 번역해주세요. 번역만 출력하고 다른 설명은 하지 마세요. + +${text}`; + + try { + const response = await ai.models.generateContent({ + model: selectedModel, + contents: prompt, + }); + + const translatedText = response.text; + + if (!translatedText) { + throw new Error("번역 결과를 가져올 수 없습니다."); + } + + return translatedText; + } catch (error) { + console.error("Gemini 번역 오류:", error); + throw new Error(error.message || "번역 중 오류가 발생했습니다."); + } +}; + +/** + * Gemini 설정 조회 (DB에서 모델 목록 가져오기) + */ +const getGeminiConfig = async () => { + const apiKeyRecord = await SystemConfig.findOne({ + where: { key: "gemini_api_key" }, + }); + const modelRecord = await SystemConfig.findOne({ + where: { key: "gemini_model" }, + }); + + // DB에서 모델 목록 조회 + const models = await listModels(); + + return { + gemini_api_key: apiKeyRecord?.value || "", + gemini_model: modelRecord?.value || "gemini-2.5-flash", + models, + }; +}; + +/** + * Gemini 설정 저장 + */ +const saveGeminiConfig = async (config) => { + // API 키가 마스킹된 값이 아닐 때만 저장 (********로 시작하지 않을 때) + if ( + config.gemini_api_key !== undefined && + !config.gemini_api_key.includes("*") + ) { + await SystemConfig.upsert({ + key: "gemini_api_key", + value: config.gemini_api_key, + }); + } + if (config.gemini_model !== undefined) { + await SystemConfig.upsert({ + key: "gemini_model", + value: config.gemini_model, + }); + } +}; + +module.exports = { + translateText, + getGeminiConfig, + saveGeminiConfig, + listModels, +}; diff --git a/backend/services/imapService.js b/backend/services/imapService.js new file mode 100644 index 0000000..ec57075 --- /dev/null +++ b/backend/services/imapService.js @@ -0,0 +1,150 @@ +const getMailboxes = async (user, password) => { + return ["INBOX", "Sent", "Drafts", "Junk", "Trash", "Important"]; +}; + +const mockEmails = [ + { + id: "1", + seq: 1, + from: "Google ", + subject: "새로운 기기에서 로그인됨", + date: new Date().toISOString(), + flags: [], + snippet: + "Windows 환경의 Chrome 브라우저에서 귀하의 Google 계정에 로그인했습니다. 본인의 활동이 맞다면 이 이메일을 무시하셔도 됩니다.", + }, + { + id: "2", + seq: 2, + from: "Amazon Web Services ", + subject: "AWS Free Tier 사용량 알림", + date: new Date(Date.now() - 3600000).toISOString(), + flags: ["\\Seen"], + snippet: + "현재 AWS 프리 티어 사용량이 한도에 근접했습니다. 청구 대시보드에서 현재 사용량을 확인하고 예상 비용을 관리하세요.", + }, + { + id: "3", + seq: 3, + from: "Slack ", + subject: "[Slack] 새로운 멘션이 있습니다", + date: new Date(Date.now() - 86400000).toISOString(), + flags: ["\\Seen", "\\Flagged"], // Starred + snippet: + "frontend-team 채널에서 @caadiq 님을 멘션했습니다: '이번 주 스프린트 계획 회의 시간 확인 부탁드립니다.'", + }, + { + id: "4", + seq: 4, + from: "Github ", + subject: "Security alert for your repository", + date: new Date(Date.now() - 172800000).toISOString(), + flags: ["\\Seen"], + snippet: + "We found a potential security vulnerability in one of your dependencies. A Dependabot alert has been created.", + }, + { + id: "5", + seq: 5, + from: "Notion ", + subject: "12월 제품 업데이트 소식", + date: new Date(Date.now() - 259200000).toISOString(), + flags: [], + snippet: + "이번 달 업데이트: 새로운 데이터베이스 보기 옵션, 향상된 검색 기능, 그리고 모바일 앱 성능 개선사항을 확인해보세요.", + }, + { + id: "6", + seq: 6, + from: "쿠팡 ", + subject: "주문하신 상품이 배송 완료되었습니다", + date: new Date(Date.now() - 432000000).toISOString(), + flags: ["\\Seen"], + snippet: + "고객님께서 주문하신 '맥북 프로 M3 Max' 상품이 문 앞에 배송되었습니다. 사진을 확인해주세요.", + }, + { + id: "7", + seq: 7, + from: "Toss Team ", + subject: "12월 카드 명세서가 도착했습니다", + date: new Date(Date.now() - 604800000).toISOString(), + flags: ["\\Seen"], + snippet: + "이번 달 총 사용 금액은 1,250,000원입니다. 결제일은 25일이며, 자세한 내역은 앱에서 확인하실 수 있습니다.", + }, +]; + +const getEmails = async (user, password, boxName = "INBOX", limit = 20) => { + // Simulate network delay + // await new Promise((resolve) => setTimeout(resolve, 500)); + + let filtered = [...mockEmails]; + + // Simple Mock Filtering Logic + if (boxName === "Important") { + console.log("Fetching Important (Mock)"); + // Return items that match the user's idea of 'Important' or just specific mock items + // In real IMAP we used FLAGGED. Let's filter by flag here too if we want realism, + // or just return specific ones. + // Let's rely on the mock data having flags. + return filtered.filter((e) => e.flags && e.flags.includes("\\Flagged")); + } + + if (boxName === "Sent") { + return [ + { + id: "101", + from: "Me ", + subject: "Re: 프로젝트 일정 공유", + date: new Date().toISOString(), + flags: ["\\Seen"], + snippet: "네, 확인했습니다. 말씀하신 대로 진행하겠습니다.", + }, + ]; + } + + if (boxName === "Drafts") { + return [ + { + id: "201", + from: "Me ", + subject: "제안서 초안", + date: new Date().toISOString(), + flags: [], + snippet: "안녕하세요 대표님, 이번 프로젝트 제안서 초안 송부드립니다...", + }, + ]; + } + + if (boxName === "Junk" || boxName === "spam") { + return []; + } + + if (boxName === "Trash") { + return [ + { + id: "999", + from: "Spam ", + subject: "You won a lottery!", + date: new Date(Date.now() - 100000000).toISOString(), + flags: ["\\Seen"], + snippet: "Click here to claim your prize...", + }, + ]; + } + + // Default INBOX return + return filtered; +}; + +const connect = async () => { + // No-op for mock + return true; +}; + +module.exports = { + connect, + getMailboxes, + getEmails, +}; diff --git a/backend/services/rspamdService.js b/backend/services/rspamdService.js new file mode 100644 index 0000000..e05e1ba --- /dev/null +++ b/backend/services/rspamdService.js @@ -0,0 +1,174 @@ +/** + * rspamd 스팸 필터 서비스 + * 스팸 검사, 학습 API 연동 + */ + +const RSPAMD_HOST = process.env.RSPAMD_HOST || "rspamd"; +const RSPAMD_PORT = process.env.RSPAMD_PORT || 11333; +const RSPAMD_BASE_URL = `http://${RSPAMD_HOST}:${RSPAMD_PORT}`; + +// 스팸 점수 임계값 (이 값 이상이면 스팸으로 분류) +const SPAM_THRESHOLD = 6.0; + +/** + * 이메일 스팸 검사 + * @param {string} rawEmail - 원본 이메일 데이터 (RFC822 형식) + * @returns {Promise<{isSpam: boolean, score: number, action: string}>} + */ +const checkSpam = async (rawEmail) => { + try { + const response = await fetch(`${RSPAMD_BASE_URL}/checkv2`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: rawEmail, + }); + + if (!response.ok) { + console.error("[rspamd] 검사 실패:", response.status); + return { isSpam: false, score: 0, action: "no action" }; + } + + const result = await response.json(); + + // action: no action, greylist, add header, rewrite subject, soft reject, reject + const isSpam = + result.score >= SPAM_THRESHOLD || + result.action === "reject" || + result.action === "add header" || + result.action === "rewrite subject"; + + console.log( + `[rspamd] 검사 결과: score=${result.score}, action=${result.action}, isSpam=${isSpam}` + ); + + return { + isSpam, + score: result.score || 0, + action: result.action || "no action", + symbols: result.symbols || {}, + }; + } catch (error) { + console.error("[rspamd] 검사 오류:", error.message); + // rspamd 연결 실패 시 스팸이 아닌 것으로 처리 + return { isSpam: false, score: 0, action: "no action" }; + } +}; + +/** + * 이메일을 스팸으로 학습 + * @param {string} rawEmail - 원본 이메일 데이터 + * @returns {Promise} + */ +const learnSpam = async (rawEmail) => { + try { + const response = await fetch(`${RSPAMD_BASE_URL}/learnspam`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: rawEmail, + }); + + if (!response.ok) { + console.error("[rspamd] 스팸 학습 실패:", response.status); + return false; + } + + const result = await response.json(); + console.log("[rspamd] 스팸 학습 완료:", result); + return result.success !== false; + } catch (error) { + console.error("[rspamd] 스팸 학습 오류:", error.message); + return false; + } +}; + +/** + * 이메일을 정상 메일(ham)로 학습 + * @param {string} rawEmail - 원본 이메일 데이터 + * @returns {Promise} + */ +const learnHam = async (rawEmail) => { + try { + const response = await fetch(`${RSPAMD_BASE_URL}/learnham`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + }, + body: rawEmail, + }); + + if (!response.ok) { + console.error("[rspamd] 햄 학습 실패:", response.status); + return false; + } + + const result = await response.json(); + console.log("[rspamd] 햄 학습 완료:", result); + return result.success !== false; + } catch (error) { + console.error("[rspamd] 햄 학습 오류:", error.message); + return false; + } +}; + +/** + * rspamd 상태 확인 + * @returns {Promise} + */ +const checkHealth = async () => { + try { + const response = await fetch(`${RSPAMD_BASE_URL}/ping`, { + method: "GET", + }); + return response.ok; + } catch { + return false; + } +}; + +/** + * 이메일 객체를 RFC822 형식으로 변환 + * @param {Object} email - 이메일 객체 (from, to, subject, text, html) + * @returns {string} + */ +const buildRawEmail = (email) => { + const boundary = "----=_Part_" + Date.now(); + let raw = ""; + + raw += `From: ${email.from || "unknown@unknown.com"}\r\n`; + raw += `To: ${email.to || "unknown@unknown.com"}\r\n`; + raw += `Subject: ${email.subject || "(no subject)"}\r\n`; + raw += `Date: ${ + email.date ? new Date(email.date).toUTCString() : new Date().toUTCString() + }\r\n`; + raw += `Message-ID: ${email.messageId || `<${Date.now()}@local>`}\r\n`; + raw += `MIME-Version: 1.0\r\n`; + + if (email.html) { + raw += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`; + raw += `--${boundary}\r\n`; + raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`; + raw += `${email.text || ""}\r\n`; + raw += `--${boundary}\r\n`; + raw += `Content-Type: text/html; charset=utf-8\r\n\r\n`; + raw += `${email.html}\r\n`; + raw += `--${boundary}--\r\n`; + } else { + raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`; + raw += `${email.text || ""}\r\n`; + } + + return raw; +}; + +module.exports = { + checkSpam, + learnSpam, + learnHam, + checkHealth, + buildRawEmail, + SPAM_THRESHOLD, +}; diff --git a/backend/services/s3Service.js b/backend/services/s3Service.js new file mode 100644 index 0000000..7e24449 --- /dev/null +++ b/backend/services/s3Service.js @@ -0,0 +1,80 @@ +/** + * S3 서비스 (Garage/MinIO 호환) + * 첨부파일 업로드 및 다운로드 + */ +const { + S3Client, + PutObjectCommand, + GetObjectCommand, +} = require("@aws-sdk/client-s3"); +const { getSignedUrl } = require("@aws-sdk/s3-request-presigner"); +const crypto = require("crypto"); + +// S3 클라이언트 설정 (Garage/MinIO 호환 모드) +const s3Client = new S3Client({ + region: process.env.AWS_REGION || "garage", + endpoint: process.env.MINIO_ENDPOINT || "http://garage:3900", + forcePathStyle: true, + credentials: { + accessKeyId: process.env.MINIO_ACCESS_KEY, + secretAccessKey: process.env.MINIO_SECRET_KEY, + }, +}); + +const BUCKET_NAME = process.env.MINIO_BUCKET || "emails"; + +/** + * 첨부파일 업로드 + * @param {Buffer} buffer - 파일 내용 + * @param {string} filename - 원본 파일명 + * @param {string} mimeType - MIME 타입 + * @returns {Promise} S3 객체 키 + */ +exports.uploadAttachment = async (buffer, filename, mimeType) => { + // 고유 키 생성: 타임스탬프_랜덤해시_파일명 + const key = `${Date.now()}_${crypto + .randomBytes(8) + .toString("hex")}_${filename}`; + + await s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: mimeType, + }) + ); + + return key; +}; + +/** + * 서명된 다운로드 URL 생성 (1시간 유효) + * @param {string} key - S3 객체 키 + * @returns {Promise} 서명된 URL + */ +exports.getAttachmentUrl = async (key) => { + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }); + return getSignedUrl(s3Client, command, { expiresIn: 3600 }); +}; + +/** + * 객체 스트림 가져오기 (직접 다운로드용) + * @param {string} key - S3 객체 키 + * @returns {Promise<{stream: ReadableStream, contentType: string, contentLength: number}>} + */ +exports.getObjectStream = async (key) => { + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }); + const response = await s3Client.send(command); + return { + stream: response.Body, + contentType: response.ContentType, + contentLength: response.ContentLength, + }; +}; diff --git a/backend/services/smtpService.js b/backend/services/smtpService.js new file mode 100644 index 0000000..8a40021 --- /dev/null +++ b/backend/services/smtpService.js @@ -0,0 +1,331 @@ +/** + * 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); + }); +}; diff --git a/backend/services/sseService.js b/backend/services/sseService.js new file mode 100644 index 0000000..13f264e --- /dev/null +++ b/backend/services/sseService.js @@ -0,0 +1,48 @@ +/** + * Server-Sent Events (SSE) 서비스 + * 실시간 메일 수신 알림 + */ + +const clients = new Set(); + +/** + * 새 클라이언트 연결 추가 + */ +exports.addClient = (res) => { + clients.add(res); + console.log(`[SSE] 클라이언트 연결: 총 ${clients.size}명`); +}; + +/** + * 클라이언트 연결 제거 + */ +exports.removeClient = (res) => { + clients.delete(res); + console.log(`[SSE] 클라이언트 연결 해제: 총 ${clients.size}명`); +}; + +/** + * 모든 클라이언트에게 새 메일 알림 전송 + */ +exports.notifyNewMail = (mailData) => { + const message = JSON.stringify({ + type: "new-mail", + data: { + from: mailData.from, + to: mailData.to, + subject: mailData.subject, + date: mailData.date, + }, + }); + + console.log(`[SSE] 새 메일 알림 전송: ${clients.size}명에게`); + + clients.forEach((client) => { + try { + client.write(`data: ${message}\n\n`); + } catch (error) { + console.error("[SSE] 메시지 전송 실패:", error); + clients.delete(client); + } + }); +}; diff --git a/backend/utils/dbInit.js b/backend/utils/dbInit.js new file mode 100644 index 0000000..acc7536 --- /dev/null +++ b/backend/utils/dbInit.js @@ -0,0 +1,67 @@ +const sequelize = require("../config/database"); +const Inbox = require("../models/Inbox"); +const Sent = require("../models/Sent"); +const Trash = require("../models/Trash"); +const Spam = require("../models/Spam"); +const Draft = require("../models/Draft"); +const Important = require("../models/Important"); +const User = require("../models/User"); +const SystemConfig = require("../models/SystemConfig"); +const SmtpLog = require("../models/SmtpLog"); +const SentLog = require("../models/SentLog"); +const GeminiModel = require("../models/GeminiModel"); +const EmailTranslation = require("../models/EmailTranslation"); +const mysql = require("mysql2/promise"); + +async function initializeDatabase() { + // 1. DB가 없으면 생성 (raw connection 사용) + try { + const connection = await mysql.createConnection({ + host: process.env.DB_HOST || "mariadb", + user: process.env.DB_USER || "root", + password: process.env.DB_PASSWORD || "password", + }); + + const dbName = process.env.DB_NAME || "mail"; + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`); + await connection.end(); + console.log(`${dbName} 데이터베이스 확인/생성됨.`); + } catch (error) { + console.error("데이터베이스 생성 오류:", error); + // 에러를 던지지 않고 sequelize 연결 시도 (DB 생성 권한 없을 경우 대비) + } + + // 2. 모델 동기화 + try { + await sequelize.authenticate(); + console.log("데이터베이스 연결 성공."); + + // 테이블이 없을 때만 생성 (기존 테이블은 변경하지 않음) + // alter: true는 인덱스 중복 생성 문제를 일으킬 수 있으므로 사용하지 않음 + await sequelize.sync({ force: false }); + + // 관리자 계정 시드 + const adminExists = await User.findOne({ where: { isAdmin: true } }); + if (!adminExists) { + const bcrypt = require("bcryptjs"); + const hashedPrice = await bcrypt.hash("admin123", 10); + await User.create({ + email: "admin@caadiq.co.kr", + password: hashedPrice, + name: "Administrator", + isAdmin: true, + }); + console.log("관리자 계정 생성됨: admin@caadiq.co.kr / admin123"); + } + + // Gemini 모델 시드 + await GeminiModel.seedDefaultModels(); + + console.log("데이터베이스 동기화 완료."); + } catch (error) { + console.error("데이터베이스 연결 실패:", error); + process.exit(1); + } +} + +module.exports = initializeDatabase; diff --git a/backend/utils/emailUtils.js b/backend/utils/emailUtils.js new file mode 100644 index 0000000..f1c657a --- /dev/null +++ b/backend/utils/emailUtils.js @@ -0,0 +1,36 @@ +/** + * 메일 크기 계산 유틸리티 + */ + +/** + * 메일 크기 계산 (bytes) + * subject + text + html + 첨부파일 크기 합산 + * @param {object} emailData - 메일 데이터 + * @returns {number} 크기 (bytes) + */ +const calculateEmailSize = (emailData) => { + let size = 0; + if (emailData.subject) size += Buffer.byteLength(emailData.subject, "utf8"); + if (emailData.text) size += Buffer.byteLength(emailData.text, "utf8"); + if (emailData.html) size += Buffer.byteLength(emailData.html, "utf8"); + + let atts = emailData.attachments || []; + + // JSON 문자열인 경우 파싱 + if (typeof atts === "string") { + try { + atts = JSON.parse(atts); + } catch { + atts = []; + } + } + + if (Array.isArray(atts)) { + atts.forEach((att) => { + size += att.size || 0; + }); + } + return size; +}; + +module.exports = { calculateEmailSize }; diff --git a/backend/utils/helpers.js b/backend/utils/helpers.js new file mode 100644 index 0000000..78b1a1b --- /dev/null +++ b/backend/utils/helpers.js @@ -0,0 +1,204 @@ +/** + * 백엔드 공통 헬퍼 함수 + * mail.js, admin.js 등에서 공통으로 사용되는 유틸리티 + */ + +const Inbox = require("../models/Inbox"); +const Sent = require("../models/Sent"); +const Trash = require("../models/Trash"); +const Spam = require("../models/Spam"); +const Draft = require("../models/Draft"); +const Important = require("../models/Important"); + +// 메일함 이름으로 Sequelize 모델 반환 맵 +const MAILBOX_MODELS = { + INBOX: Inbox, + SENT: Sent, + TRASH: Trash, + SPAM: Spam, + DRAFTS: Draft, + IMPORTANT: Important, +}; + +/** + * 메일함 이름으로 Sequelize 모델 반환 + * @param {string} mailboxName - 메일함 이름 (INBOX, SENT, TRASH 등) + * @returns {Model} Sequelize 모델 + */ +const getModel = (mailboxName = "INBOX") => { + return MAILBOX_MODELS[mailboxName.toUpperCase()] || Inbox; +}; + +/** + * 이메일 데이터 정규화 (프론트엔드 호환성) + * @param {Object} email - 이메일 객체 + * @param {string} mailboxName - 메일함 이름 + * @returns {Object} 정규화된 이메일 데이터 + */ +const normalizeEmail = (email, mailboxName) => { + if (!email) return null; + const data = email.toJSON ? email.toJSON() : email; + data.mailbox = mailboxName.toUpperCase(); + + // 보낸편지함 호환성: body 필드가 있고 html이 없으면 body를 html로 복사 + if (data.body && !data.html) { + data.html = data.body; + } + + return data; +}; + +/** + * 스토리지 사용량 계산 (본문 + 첨부파일 크기) + * mail.js와 admin.js에서 동일하게 사용 + * @param {Array} items - 메일 항목 배열 + * @returns {number} 총 바이트 크기 + */ +const calculateStorageSize = (items) => { + let totalSize = 0; + 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((att) => (totalSize += att.size || 0)); + } + }); + return totalSize; +}; + +/** + * 첨부파일 배열 파싱 (JSON 문자열 처리) + * @param {Array|string} attachments - 첨부파일 데이터 + * @returns {Array} 파싱된 첨부파일 배열 + */ +const parseAttachments = (attachments) => { + if (!attachments) return []; + if (Array.isArray(attachments)) return attachments; + if (typeof attachments === "string") { + try { + let parsed = JSON.parse(attachments); + // 이중 JSON 문자열 처리 + if (typeof parsed === "string") parsed = JSON.parse(parsed); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +}; + +/** + * 안전한 트랜잭션 롤백 헬퍼 + * 연결이 끊어진 경우에도 서버 크래시 방지 + * @param {Transaction} transaction - Sequelize 트랜잭션 + */ +const safeRollback = async (transaction) => { + try { + await transaction.rollback(); + } catch (rollbackError) { + console.error("롤백 오류 (무시됨):", rollbackError.message); + } +}; + +/** + * 이메일 문자열에서 순수 이메일 주소만 추출 + * "Name" 또는 email@domain.com 형태 모두 지원 + * @param {string} emailStr - 이메일 문자열 + * @returns {string} 순수 이메일 주소 + */ +const extractEmailAddress = (emailStr) => { + if (!emailStr) return ""; + // 형태에서 추출 + const match = emailStr.match(/<([^>]+)>/); + if (match) return match[1].toLowerCase(); + // 그냥 이메일만 있는 경우 + return emailStr.trim().toLowerCase(); +}; + +/** + * 이메일 조회 (소유권 검증 포함) + * @param {number} id - 이메일 ID + * @param {string} userEmail - 사용자 이메일 + * @param {string} mailboxContext - 메일함 컨텍스트 (선택) + * @returns {Promise} { email, Model, box } 또는 null + */ +const findEmail = async (id, userEmail, mailboxContext) => { + const userEmailLower = userEmail.toLowerCase(); + + // 메일함이 명시된 경우 해당 모델에서 직접 조회 + if (mailboxContext) { + const Model = getModel(mailboxContext); + const email = await Model.findByPk(id); + if (email) { + // from 필드에서 이메일 주소만 추출하여 비교 + const fromEmail = extractEmailAddress(email.from); + const toContains = + email.to && email.to.toLowerCase().includes(userEmailLower); + const isOwner = fromEmail === userEmailLower || toContains; + if (isOwner) return { email, Model, box: mailboxContext }; + } + return null; + } + + // 폴백: 모든 메일함 순회 (mailboxContext 없이 호출 시) + const boxList = ["INBOX", "SENT", "TRASH", "SPAM", "DRAFTS", "IMPORTANT"]; + for (const box of boxList) { + const Model = getModel(box); + const email = await Model.findByPk(id); + if (email) { + const fromEmail = extractEmailAddress(email.from); + const toContains = + email.to && email.to.toLowerCase().includes(userEmailLower); + const isOwner = fromEmail === userEmailLower || toContains; + if (isOwner) return { email, Model, box }; + } + } + return null; +}; + +/** + * 기간 시작일 계산 (1d, 7d, 30d, all) + * @param {string} period - 기간 문자열 + * @returns {Date|null} 시작일 또는 null (전체 기간) + */ +const getPeriodStartDate = (period) => { + const periodStart = new Date(); + switch (period) { + case "1d": + periodStart.setDate(periodStart.getDate() - 1); + return periodStart; + case "7d": + periodStart.setDate(periodStart.getDate() - 7); + return periodStart; + case "30d": + periodStart.setDate(periodStart.getDate() - 30); + return periodStart; + default: + return null; + } +}; + +module.exports = { + getModel, + normalizeEmail, + calculateStorageSize, + parseAttachments, + safeRollback, + findEmail, + getPeriodStartDate, + extractEmailAddress, + MAILBOX_MODELS, +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f1b7451 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + backend: + build: ./backend + container_name: email-backend + ports: + - "25:25" + volumes: + - ./backend:/app + - /app/node_modules + env_file: + - .env + environment: + RSPAMD_HOST: rspamd + RSPAMD_PORT: 11333 + depends_on: + - rspamd + networks: + - app + - db + restart: unless-stopped + + frontend: + build: ./frontend + container_name: email-frontend + depends_on: + - backend + networks: + - app + restart: unless-stopped + + rspamd: + image: rspamd/rspamd:latest + container_name: email-rspamd + volumes: + - ./rspamd/local.d:/etc/rspamd/local.d:ro + - rspamd-data:/var/lib/rspamd + depends_on: + - redis + networks: + - app + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: email-redis + volumes: + - redis-data:/data + networks: + - app + restart: unless-stopped + +networks: + app: + external: true + db: + external: true + +volumes: + rspamd-data: + redis-data: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b6163fc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,25 @@ +# 빌드 스테이지 +FROM node:18-alpine AS build + +WORKDIR /app + +# 패키지 설치 +COPY package*.json ./ +RUN npm ci + +# 소스 복사 및 빌드 +COPY . . +RUN npm run build + +# 프로덕션 스테이지 +FROM nginx:alpine + +# nginx 설정 복사 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 빌드된 파일 복사 +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/dist/assets/index-4CluA2BD.js b/frontend/dist/assets/index-4CluA2BD.js new file mode 100644 index 0000000..00c30e0 --- /dev/null +++ b/frontend/dist/assets/index-4CluA2BD.js @@ -0,0 +1,794 @@ +var J5=Object.defineProperty;var e4=(e,t,r)=>t in e?J5(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var me=(e,t,r)=>e4(e,typeof t!="symbol"?t+"":t,r);function t4(e,t){for(var r=0;rn[a]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))n(a);new MutationObserver(a=>{for(const o of a)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&n(i)}).observe(document,{childList:!0,subtree:!0});function r(a){const o={};return a.integrity&&(o.integrity=a.integrity),a.referrerPolicy&&(o.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?o.credentials="include":a.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(a){if(a.ep)return;a.ep=!0;const o=r(a);fetch(a.href,o)}})();function ti(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var KD={exports:{}},ng={},qD={exports:{}},Ye={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Yd=Symbol.for("react.element"),r4=Symbol.for("react.portal"),n4=Symbol.for("react.fragment"),a4=Symbol.for("react.strict_mode"),o4=Symbol.for("react.profiler"),i4=Symbol.for("react.provider"),s4=Symbol.for("react.context"),l4=Symbol.for("react.forward_ref"),c4=Symbol.for("react.suspense"),u4=Symbol.for("react.memo"),d4=Symbol.for("react.lazy"),jk=Symbol.iterator;function f4(e){return e===null||typeof e!="object"?null:(e=jk&&e[jk]||e["@@iterator"],typeof e=="function"?e:null)}var GD={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},XD=Object.assign,QD={};function Oc(e,t,r){this.props=e,this.context=t,this.refs=QD,this.updater=r||GD}Oc.prototype.isReactComponent={};Oc.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};Oc.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function ZD(){}ZD.prototype=Oc.prototype;function Gb(e,t,r){this.props=e,this.context=t,this.refs=QD,this.updater=r||GD}var Xb=Gb.prototype=new ZD;Xb.constructor=Gb;XD(Xb,Oc.prototype);Xb.isPureReactComponent=!0;var Ak=Array.isArray,JD=Object.prototype.hasOwnProperty,Qb={current:null},e_={key:!0,ref:!0,__self:!0,__source:!0};function t_(e,t,r){var n,a={},o=null,i=null;if(t!=null)for(n in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(o=""+t.key),t)JD.call(t,n)&&!e_.hasOwnProperty(n)&&(a[n]=t[n]);var s=arguments.length-2;if(s===1)a.children=r;else if(1>>1,F=I[V];if(0>>1;Va(X,z))fea(q,X)?(I[V]=q,I[fe]=z,V=fe):(I[V]=X,I[ee]=z,V=ee);else if(fea(q,z))I[V]=q,I[fe]=z,V=fe;else break e}}return $}function a(I,$){var z=I.sortIndex-$.sortIndex;return z!==0?z:I.id-$.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var i=Date,s=i.now();e.unstable_now=function(){return i.now()-s}}var l=[],c=[],u=1,d=null,f=3,p=!1,g=!1,y=!1,v=typeof setTimeout=="function"?setTimeout:null,x=typeof clearTimeout=="function"?clearTimeout:null,w=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function b(I){for(var $=r(c);$!==null;){if($.callback===null)n(c);else if($.startTime<=I)n(c),$.sortIndex=$.expirationTime,t(l,$);else break;$=r(c)}}function S(I){if(y=!1,b(I),!g)if(r(l)!==null)g=!0,A(k);else{var $=r(c);$!==null&&L(S,$.startTime-I)}}function k(I,$){g=!1,y&&(y=!1,x(O),O=-1),p=!0;var z=f;try{for(b($),d=r(l);d!==null&&(!(d.expirationTime>$)||I&&!_());){var V=d.callback;if(typeof V=="function"){d.callback=null,f=d.priorityLevel;var F=V(d.expirationTime<=$);$=e.unstable_now(),typeof F=="function"?d.callback=F:d===r(l)&&n(l),b($)}else n(l);d=r(l)}if(d!==null)var ne=!0;else{var ee=r(c);ee!==null&&L(S,ee.startTime-$),ne=!1}return ne}finally{d=null,f=z,p=!1}}var P=!1,E=null,O=-1,D=5,N=-1;function _(){return!(e.unstable_now()-NI||125V?(I.sortIndex=z,t(c,I),r(l)===null&&I===r(c)&&(y?(x(O),O=-1):y=!0,L(S,z-V))):(I.sortIndex=F,t(l,I),g||p||(g=!0,A(k))),I},e.unstable_shouldYield=_,e.unstable_wrapCallback=function(I){var $=f;return function(){var z=f;f=$;try{return I.apply(this,arguments)}finally{f=z}}}})(i_);o_.exports=i_;var k4=o_.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var C4=m,On=k4;function se(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,r=1;r"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),_0=Object.prototype.hasOwnProperty,P4=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ik={},$k={};function E4(e){return _0.call($k,e)?!0:_0.call(Ik,e)?!1:P4.test(e)?$k[e]=!0:(Ik[e]=!0,!1)}function O4(e,t,r,n){if(r!==null&&r.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return n?!1:r!==null?!r.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function D4(e,t,r,n){if(t===null||typeof t>"u"||O4(e,t,r,n))return!0;if(n)return!1;if(r!==null)switch(r.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function Vr(e,t,r,n,a,o,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=n,this.attributeNamespace=a,this.mustUseProperty=r,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=i}var Cr={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Cr[e]=new Vr(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];Cr[t]=new Vr(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Cr[e]=new Vr(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Cr[e]=new Vr(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Cr[e]=new Vr(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Cr[e]=new Vr(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Cr[e]=new Vr(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Cr[e]=new Vr(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Cr[e]=new Vr(e,5,!1,e.toLowerCase(),null,!1,!1)});var Jb=/[\-:]([a-z])/g;function ew(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Jb,ew);Cr[t]=new Vr(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Jb,ew);Cr[t]=new Vr(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Jb,ew);Cr[t]=new Vr(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Cr[e]=new Vr(e,1,!1,e.toLowerCase(),null,!1,!1)});Cr.xlinkHref=new Vr("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Cr[e]=new Vr(e,1,!1,e.toLowerCase(),null,!0,!0)});function tw(e,t,r,n){var a=Cr.hasOwnProperty(t)?Cr[t]:null;(a!==null?a.type!==0:n||!(2s||a[i]!==o[s]){var l=` +`+a[i].replace(" at new "," at ");return e.displayName&&l.includes("")&&(l=l.replace("",e.displayName)),l}while(1<=i&&0<=s);break}}}finally{dy=!1,Error.prepareStackTrace=r}return(e=e?e.displayName||e.name:"")?wu(e):""}function _4(e){switch(e.tag){case 5:return wu(e.type);case 16:return wu("Lazy");case 13:return wu("Suspense");case 19:return wu("SuspenseList");case 0:case 2:case 15:return e=fy(e.type,!1),e;case 11:return e=fy(e.type.render,!1),e;case 1:return e=fy(e.type,!0),e;default:return""}}function j0(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case wl:return"Fragment";case bl:return"Portal";case N0:return"Profiler";case rw:return"StrictMode";case M0:return"Suspense";case T0:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case c_:return(e.displayName||"Context")+".Consumer";case l_:return(e._context.displayName||"Context")+".Provider";case nw:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case aw:return t=e.displayName||null,t!==null?t:j0(e.type)||"Memo";case xi:t=e._payload,e=e._init;try{return j0(e(t))}catch{}}return null}function N4(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return j0(t);case 8:return t===rw?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function Hi(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function d_(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function M4(e){var t=d_(e)?"checked":"value",r=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),n=""+e[t];if(!e.hasOwnProperty(t)&&typeof r<"u"&&typeof r.get=="function"&&typeof r.set=="function"){var a=r.get,o=r.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return a.call(this)},set:function(i){n=""+i,o.call(this,i)}}),Object.defineProperty(e,t,{enumerable:r.enumerable}),{getValue:function(){return n},setValue:function(i){n=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function If(e){e._valueTracker||(e._valueTracker=M4(e))}function f_(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var r=t.getValue(),n="";return e&&(n=d_(e)?e.checked?"true":"false":e.value),e=n,e!==r?(t.setValue(e),!0):!1}function hh(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function A0(e,t){var r=t.checked;return Rt({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:r??e._wrapperState.initialChecked})}function Fk(e,t){var r=t.defaultValue==null?"":t.defaultValue,n=t.checked!=null?t.checked:t.defaultChecked;r=Hi(t.value!=null?t.value:r),e._wrapperState={initialChecked:n,initialValue:r,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function p_(e,t){t=t.checked,t!=null&&tw(e,"checked",t,!1)}function R0(e,t){p_(e,t);var r=Hi(t.value),n=t.type;if(r!=null)n==="number"?(r===0&&e.value===""||e.value!=r)&&(e.value=""+r):e.value!==""+r&&(e.value=""+r);else if(n==="submit"||n==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?I0(e,t.type,r):t.hasOwnProperty("defaultValue")&&I0(e,t.type,Hi(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function zk(e,t,r){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var n=t.type;if(!(n!=="submit"&&n!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,r||t===e.value||(e.value=t),e.defaultValue=t}r=e.name,r!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,r!==""&&(e.name=r)}function I0(e,t,r){(t!=="number"||hh(e.ownerDocument)!==e)&&(r==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+r&&(e.defaultValue=""+r))}var Su=Array.isArray;function Rl(e,t,r,n){if(e=e.options,t){t={};for(var a=0;a"+t.valueOf().toString()+"",t=$f.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function rd(e,t){if(t){var r=e.firstChild;if(r&&r===e.lastChild&&r.nodeType===3){r.nodeValue=t;return}}e.textContent=t}var Iu={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},T4=["Webkit","ms","Moz","O"];Object.keys(Iu).forEach(function(e){T4.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Iu[t]=Iu[e]})});function v_(e,t,r){return t==null||typeof t=="boolean"||t===""?"":r||typeof t!="number"||t===0||Iu.hasOwnProperty(e)&&Iu[e]?(""+t).trim():t+"px"}function y_(e,t){e=e.style;for(var r in t)if(t.hasOwnProperty(r)){var n=r.indexOf("--")===0,a=v_(r,t[r],n);r==="float"&&(r="cssFloat"),n?e.setProperty(r,a):e[r]=a}}var j4=Rt({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function F0(e,t){if(t){if(j4[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(se(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(se(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(se(61))}if(t.style!=null&&typeof t.style!="object")throw Error(se(62))}}function z0(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var B0=null;function ow(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var W0=null,Il=null,$l=null;function Hk(e){if(e=qd(e)){if(typeof W0!="function")throw Error(se(280));var t=e.stateNode;t&&(t=lg(t),W0(e.stateNode,e.type,t))}}function x_(e){Il?$l?$l.push(e):$l=[e]:Il=e}function b_(){if(Il){var e=Il,t=$l;if($l=Il=null,Hk(e),t)for(e=0;e>>=0,e===0?32:31-(U4(e)/Y4|0)|0}var Lf=64,Ff=4194304;function ku(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function yh(e,t){var r=e.pendingLanes;if(r===0)return 0;var n=0,a=e.suspendedLanes,o=e.pingedLanes,i=r&268435455;if(i!==0){var s=i&~a;s!==0?n=ku(s):(o&=i,o!==0&&(n=ku(o)))}else i=r&~a,i!==0?n=ku(i):o!==0&&(n=ku(o));if(n===0)return 0;if(t!==0&&t!==n&&!(t&a)&&(a=n&-n,o=t&-t,a>=o||a===16&&(o&4194240)!==0))return t;if(n&4&&(n|=r&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=n;0r;r++)t.push(e);return t}function Vd(e,t,r){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ca(t),e[t]=r}function G4(e,t){var r=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var n=e.eventTimes;for(e=e.expirationTimes;0=Lu),Zk=" ",Jk=!1;function z_(e,t){switch(e){case"keyup":return kL.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function B_(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Sl=!1;function PL(e,t){switch(e){case"compositionend":return B_(t);case"keypress":return t.which!==32?null:(Jk=!0,Zk);case"textInput":return e=t.data,e===Zk&&Jk?null:e;default:return null}}function EL(e,t){if(Sl)return e==="compositionend"||!pw&&z_(e,t)?(e=L_(),Lp=uw=Oi=null,Sl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:r,offset:t-e};e=n}e:{for(;r;){if(r.nextSibling){r=r.nextSibling;break e}r=r.parentNode}r=void 0}r=nC(r)}}function Y_(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Y_(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function V_(){for(var e=window,t=hh();t instanceof e.HTMLIFrameElement;){try{var r=typeof t.contentWindow.location.href=="string"}catch{r=!1}if(r)e=t.contentWindow;else break;t=hh(e.document)}return t}function hw(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function RL(e){var t=V_(),r=e.focusedElem,n=e.selectionRange;if(t!==r&&r&&r.ownerDocument&&Y_(r.ownerDocument.documentElement,r)){if(n!==null&&hw(r)){if(t=n.start,e=n.end,e===void 0&&(e=t),"selectionStart"in r)r.selectionStart=t,r.selectionEnd=Math.min(e,r.value.length);else if(e=(t=r.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var a=r.textContent.length,o=Math.min(n.start,a);n=n.end===void 0?o:Math.min(n.end,a),!e.extend&&o>n&&(a=n,n=o,o=a),a=aC(r,o);var i=aC(r,n);a&&i&&(e.rangeCount!==1||e.anchorNode!==a.node||e.anchorOffset!==a.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(a.node,a.offset),e.removeAllRanges(),o>n?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=r;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof r.focus=="function"&&r.focus(),r=0;r=document.documentMode,kl=null,q0=null,zu=null,G0=!1;function oC(e,t,r){var n=r.window===r?r.document:r.nodeType===9?r:r.ownerDocument;G0||kl==null||kl!==hh(n)||(n=kl,"selectionStart"in n&&hw(n)?n={start:n.selectionStart,end:n.selectionEnd}:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection(),n={anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}),zu&&ld(zu,n)||(zu=n,n=wh(q0,"onSelect"),0El||(e.current=tx[El],tx[El]=null,El--)}function bt(e,t){El++,tx[El]=e.current,e.current=t}var Ui={},$r=Qi(Ui),en=Qi(!1),Ls=Ui;function Xl(e,t){var r=e.type.contextTypes;if(!r)return Ui;var n=e.stateNode;if(n&&n.__reactInternalMemoizedUnmaskedChildContext===t)return n.__reactInternalMemoizedMaskedChildContext;var a={},o;for(o in r)a[o]=t[o];return n&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=a),a}function tn(e){return e=e.childContextTypes,e!=null}function kh(){Ct(en),Ct($r)}function fC(e,t,r){if($r.current!==Ui)throw Error(se(168));bt($r,t),bt(en,r)}function tN(e,t,r){var n=e.stateNode;if(t=t.childContextTypes,typeof n.getChildContext!="function")return r;n=n.getChildContext();for(var a in n)if(!(a in t))throw Error(se(108,N4(e)||"Unknown",a));return Rt({},r,n)}function Ch(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ui,Ls=$r.current,bt($r,e),bt(en,en.current),!0}function pC(e,t,r){var n=e.stateNode;if(!n)throw Error(se(169));r?(e=tN(e,t,Ls),n.__reactInternalMemoizedMergedChildContext=e,Ct(en),Ct($r),bt($r,e)):Ct(en),bt(en,r)}var wo=null,cg=!1,Ey=!1;function rN(e){wo===null?wo=[e]:wo.push(e)}function KL(e){cg=!0,rN(e)}function Zi(){if(!Ey&&wo!==null){Ey=!0;var e=0,t=mt;try{var r=wo;for(mt=1;e>=i,a-=i,Po=1<<32-Ca(t)+a|r<O?(D=E,E=null):D=E.sibling;var N=f(x,E,b[O],S);if(N===null){E===null&&(E=D);break}e&&E&&N.alternate===null&&t(x,E),w=o(N,w,O),P===null?k=N:P.sibling=N,P=N,E=D}if(O===b.length)return r(x,E),Dt&&ls(x,O),k;if(E===null){for(;OO?(D=E,E=null):D=E.sibling;var _=f(x,E,N.value,S);if(_===null){E===null&&(E=D);break}e&&E&&_.alternate===null&&t(x,E),w=o(_,w,O),P===null?k=_:P.sibling=_,P=_,E=D}if(N.done)return r(x,E),Dt&&ls(x,O),k;if(E===null){for(;!N.done;O++,N=b.next())N=d(x,N.value,S),N!==null&&(w=o(N,w,O),P===null?k=N:P.sibling=N,P=N);return Dt&&ls(x,O),k}for(E=n(x,E);!N.done;O++,N=b.next())N=p(E,x,O,N.value,S),N!==null&&(e&&N.alternate!==null&&E.delete(N.key===null?O:N.key),w=o(N,w,O),P===null?k=N:P.sibling=N,P=N);return e&&E.forEach(function(C){return t(x,C)}),Dt&&ls(x,O),k}function v(x,w,b,S){if(typeof b=="object"&&b!==null&&b.type===wl&&b.key===null&&(b=b.props.children),typeof b=="object"&&b!==null){switch(b.$$typeof){case Rf:e:{for(var k=b.key,P=w;P!==null;){if(P.key===k){if(k=b.type,k===wl){if(P.tag===7){r(x,P.sibling),w=a(P,b.props.children),w.return=x,x=w;break e}}else if(P.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===xi&&gC(k)===P.type){r(x,P.sibling),w=a(P,b.props),w.ref=Jc(x,P,b),w.return=x,x=w;break e}r(x,P);break}else t(x,P);P=P.sibling}b.type===wl?(w=_s(b.props.children,x.mode,S,b.key),w.return=x,x=w):(S=Vp(b.type,b.key,b.props,null,x.mode,S),S.ref=Jc(x,w,b),S.return=x,x=S)}return i(x);case bl:e:{for(P=b.key;w!==null;){if(w.key===P)if(w.tag===4&&w.stateNode.containerInfo===b.containerInfo&&w.stateNode.implementation===b.implementation){r(x,w.sibling),w=a(w,b.children||[]),w.return=x,x=w;break e}else{r(x,w);break}else t(x,w);w=w.sibling}w=Ay(b,x.mode,S),w.return=x,x=w}return i(x);case xi:return P=b._init,v(x,w,P(b._payload),S)}if(Su(b))return g(x,w,b,S);if(qc(b))return y(x,w,b,S);Vf(x,b)}return typeof b=="string"&&b!==""||typeof b=="number"?(b=""+b,w!==null&&w.tag===6?(r(x,w.sibling),w=a(w,b),w.return=x,x=w):(r(x,w),w=jy(b,x.mode,S),w.return=x,x=w),i(x)):r(x,w)}return v}var Zl=iN(!0),sN=iN(!1),Oh=Qi(null),Dh=null,_l=null,yw=null;function xw(){yw=_l=Dh=null}function bw(e){var t=Oh.current;Ct(Oh),e._currentValue=t}function ax(e,t,r){for(;e!==null;){var n=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,n!==null&&(n.childLanes|=t)):n!==null&&(n.childLanes&t)!==t&&(n.childLanes|=t),e===r)break;e=e.return}}function Fl(e,t){Dh=e,yw=_l=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(Zr=!0),e.firstContext=null)}function Gn(e){var t=e._currentValue;if(yw!==e)if(e={context:e,memoizedValue:t,next:null},_l===null){if(Dh===null)throw Error(se(308));_l=e,Dh.dependencies={lanes:0,firstContext:e}}else _l=_l.next=e;return t}var ys=null;function ww(e){ys===null?ys=[e]:ys.push(e)}function lN(e,t,r,n){var a=t.interleaved;return a===null?(r.next=r,ww(t)):(r.next=a.next,a.next=r),t.interleaved=r,Fo(e,n)}function Fo(e,t){e.lanes|=t;var r=e.alternate;for(r!==null&&(r.lanes|=t),r=e,e=e.return;e!==null;)e.childLanes|=t,r=e.alternate,r!==null&&(r.childLanes|=t),r=e,e=e.return;return r.tag===3?r.stateNode:null}var bi=!1;function Sw(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function cN(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function No(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function Ai(e,t,r){var n=e.updateQueue;if(n===null)return null;if(n=n.shared,et&2){var a=n.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),n.pending=t,Fo(e,r)}return a=n.interleaved,a===null?(t.next=t,ww(n)):(t.next=a.next,a.next=t),n.interleaved=t,Fo(e,r)}function zp(e,t,r){if(t=t.updateQueue,t!==null&&(t=t.shared,(r&4194240)!==0)){var n=t.lanes;n&=e.pendingLanes,r|=n,t.lanes=r,sw(e,r)}}function vC(e,t){var r=e.updateQueue,n=e.alternate;if(n!==null&&(n=n.updateQueue,r===n)){var a=null,o=null;if(r=r.firstBaseUpdate,r!==null){do{var i={eventTime:r.eventTime,lane:r.lane,tag:r.tag,payload:r.payload,callback:r.callback,next:null};o===null?a=o=i:o=o.next=i,r=r.next}while(r!==null);o===null?a=o=t:o=o.next=t}else a=o=t;r={baseState:n.baseState,firstBaseUpdate:a,lastBaseUpdate:o,shared:n.shared,effects:n.effects},e.updateQueue=r;return}e=r.lastBaseUpdate,e===null?r.firstBaseUpdate=t:e.next=t,r.lastBaseUpdate=t}function _h(e,t,r,n){var a=e.updateQueue;bi=!1;var o=a.firstBaseUpdate,i=a.lastBaseUpdate,s=a.shared.pending;if(s!==null){a.shared.pending=null;var l=s,c=l.next;l.next=null,i===null?o=c:i.next=c,i=l;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==i&&(s===null?u.firstBaseUpdate=c:s.next=c,u.lastBaseUpdate=l))}if(o!==null){var d=a.baseState;i=0,u=c=l=null,s=o;do{var f=s.lane,p=s.eventTime;if((n&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});e:{var g=e,y=s;switch(f=t,p=r,y.tag){case 1:if(g=y.payload,typeof g=="function"){d=g.call(p,d,f);break e}d=g;break e;case 3:g.flags=g.flags&-65537|128;case 0:if(g=y.payload,f=typeof g=="function"?g.call(p,d,f):g,f==null)break e;d=Rt({},d,f);break e;case 2:bi=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=a.effects,f===null?a.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(c=u=p,l=d):u=u.next=p,i|=f;if(s=s.next,s===null){if(s=a.shared.pending,s===null)break;f=s,s=f.next,f.next=null,a.lastBaseUpdate=f,a.shared.pending=null}}while(!0);if(u===null&&(l=d),a.baseState=l,a.firstBaseUpdate=c,a.lastBaseUpdate=u,t=a.shared.interleaved,t!==null){a=t;do i|=a.lane,a=a.next;while(a!==t)}else o===null&&(a.shared.lanes=0);Bs|=i,e.lanes=i,e.memoizedState=d}}function yC(e,t,r){if(e=t.effects,t.effects=null,e!==null)for(t=0;tr?r:4,e(!0);var n=Dy.transition;Dy.transition={};try{e(!1),t()}finally{mt=r,Dy.transition=n}}function EN(){return Xn().memoizedState}function QL(e,t,r){var n=Ii(e);if(r={lane:n,action:r,hasEagerState:!1,eagerState:null,next:null},ON(e))DN(t,r);else if(r=lN(e,t,r,n),r!==null){var a=Hr();Pa(r,e,n,a),_N(r,t,n)}}function ZL(e,t,r){var n=Ii(e),a={lane:n,action:r,hasEagerState:!1,eagerState:null,next:null};if(ON(e))DN(t,a);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var i=t.lastRenderedState,s=o(i,r);if(a.hasEagerState=!0,a.eagerState=s,Da(s,i)){var l=t.interleaved;l===null?(a.next=a,ww(t)):(a.next=l.next,l.next=a),t.interleaved=a;return}}catch{}finally{}r=lN(e,t,a,n),r!==null&&(a=Hr(),Pa(r,e,n,a),_N(r,t,n))}}function ON(e){var t=e.alternate;return e===At||t!==null&&t===At}function DN(e,t){Bu=Mh=!0;var r=e.pending;r===null?t.next=t:(t.next=r.next,r.next=t),e.pending=t}function _N(e,t,r){if(r&4194240){var n=t.lanes;n&=e.pendingLanes,r|=n,t.lanes=r,sw(e,r)}}var Th={readContext:Gn,useCallback:Dr,useContext:Dr,useEffect:Dr,useImperativeHandle:Dr,useInsertionEffect:Dr,useLayoutEffect:Dr,useMemo:Dr,useReducer:Dr,useRef:Dr,useState:Dr,useDebugValue:Dr,useDeferredValue:Dr,useTransition:Dr,useMutableSource:Dr,useSyncExternalStore:Dr,useId:Dr,unstable_isNewReconciler:!1},JL={readContext:Gn,useCallback:function(e,t){return La().memoizedState=[e,t===void 0?null:t],e},useContext:Gn,useEffect:bC,useImperativeHandle:function(e,t,r){return r=r!=null?r.concat([e]):null,Wp(4194308,4,wN.bind(null,t,e),r)},useLayoutEffect:function(e,t){return Wp(4194308,4,e,t)},useInsertionEffect:function(e,t){return Wp(4,2,e,t)},useMemo:function(e,t){var r=La();return t=t===void 0?null:t,e=e(),r.memoizedState=[e,t],e},useReducer:function(e,t,r){var n=La();return t=r!==void 0?r(t):t,n.memoizedState=n.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},n.queue=e,e=e.dispatch=QL.bind(null,At,e),[n.memoizedState,e]},useRef:function(e){var t=La();return e={current:e},t.memoizedState=e},useState:xC,useDebugValue:Nw,useDeferredValue:function(e){return La().memoizedState=e},useTransition:function(){var e=xC(!1),t=e[0];return e=XL.bind(null,e[1]),La().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,r){var n=At,a=La();if(Dt){if(r===void 0)throw Error(se(407));r=r()}else{if(r=t(),pr===null)throw Error(se(349));zs&30||pN(n,t,r)}a.memoizedState=r;var o={value:r,getSnapshot:t};return a.queue=o,bC(mN.bind(null,n,o,e),[e]),n.flags|=2048,gd(9,hN.bind(null,n,o,r,t),void 0,null),r},useId:function(){var e=La(),t=pr.identifierPrefix;if(Dt){var r=Eo,n=Po;r=(n&~(1<<32-Ca(n)-1)).toString(32)+r,t=":"+t+"R"+r,r=hd++,0<\/script>",e=e.removeChild(e.firstChild)):typeof n.is=="string"?e=i.createElement(r,{is:n.is}):(e=i.createElement(r),r==="select"&&(i=e,n.multiple?i.multiple=!0:n.size&&(i.size=n.size))):e=i.createElementNS(e,r),e[Ua]=t,e[dd]=n,FN(e,t,!1,!1),t.stateNode=e;e:{switch(i=z0(r,n),r){case"dialog":St("cancel",e),St("close",e),a=n;break;case"iframe":case"object":case"embed":St("load",e),a=n;break;case"video":case"audio":for(a=0;atc&&(t.flags|=128,n=!0,eu(o,!1),t.lanes=4194304)}else{if(!n)if(e=Nh(i),e!==null){if(t.flags|=128,n=!0,r=e.updateQueue,r!==null&&(t.updateQueue=r,t.flags|=4),eu(o,!0),o.tail===null&&o.tailMode==="hidden"&&!i.alternate&&!Dt)return _r(t),null}else 2*Yt()-o.renderingStartTime>tc&&r!==1073741824&&(t.flags|=128,n=!0,eu(o,!1),t.lanes=4194304);o.isBackwards?(i.sibling=t.child,t.child=i):(r=o.last,r!==null?r.sibling=i:t.child=i,o.last=i)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=Yt(),t.sibling=null,r=Tt.current,bt(Tt,n?r&1|2:r&1),t):(_r(t),null);case 22:case 23:return Iw(),n=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==n&&(t.flags|=8192),n&&t.mode&1?mn&1073741824&&(_r(t),t.subtreeFlags&6&&(t.flags|=8192)):_r(t),null;case 24:return null;case 25:return null}throw Error(se(156,t.tag))}function s3(e,t){switch(gw(t),t.tag){case 1:return tn(t.type)&&kh(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Jl(),Ct(en),Ct($r),Pw(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Cw(t),null;case 13:if(Ct(Tt),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(se(340));Ql()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Ct(Tt),null;case 4:return Jl(),null;case 10:return bw(t.type._context),null;case 22:case 23:return Iw(),null;case 24:return null;default:return null}}var qf=!1,Tr=!1,l3=typeof WeakSet=="function"?WeakSet:Set,be=null;function Nl(e,t){var r=e.ref;if(r!==null)if(typeof r=="function")try{r(null)}catch(n){Bt(e,t,n)}else r.current=null}function px(e,t,r){try{r()}catch(n){Bt(e,t,n)}}var MC=!1;function c3(e,t){if(X0=xh,e=V_(),hw(e)){if("selectionStart"in e)var r={start:e.selectionStart,end:e.selectionEnd};else e:{r=(r=e.ownerDocument)&&r.defaultView||window;var n=r.getSelection&&r.getSelection();if(n&&n.rangeCount!==0){r=n.anchorNode;var a=n.anchorOffset,o=n.focusNode;n=n.focusOffset;try{r.nodeType,o.nodeType}catch{r=null;break e}var i=0,s=-1,l=-1,c=0,u=0,d=e,f=null;t:for(;;){for(var p;d!==r||a!==0&&d.nodeType!==3||(s=i+a),d!==o||n!==0&&d.nodeType!==3||(l=i+n),d.nodeType===3&&(i+=d.nodeValue.length),(p=d.firstChild)!==null;)f=d,d=p;for(;;){if(d===e)break t;if(f===r&&++c===a&&(s=i),f===o&&++u===n&&(l=i),(p=d.nextSibling)!==null)break;d=f,f=d.parentNode}d=p}r=s===-1||l===-1?null:{start:s,end:l}}else r=null}r=r||{start:0,end:0}}else r=null;for(Q0={focusedElem:e,selectionRange:r},xh=!1,be=t;be!==null;)if(t=be,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,be=e;else for(;be!==null;){t=be;try{var g=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(g!==null){var y=g.memoizedProps,v=g.memoizedState,x=t.stateNode,w=x.getSnapshotBeforeUpdate(t.elementType===t.type?y:fa(t.type,y),v);x.__reactInternalSnapshotBeforeUpdate=w}break;case 3:var b=t.stateNode.containerInfo;b.nodeType===1?b.textContent="":b.nodeType===9&&b.documentElement&&b.removeChild(b.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(se(163))}}catch(S){Bt(t,t.return,S)}if(e=t.sibling,e!==null){e.return=t.return,be=e;break}be=t.return}return g=MC,MC=!1,g}function Wu(e,t,r){var n=t.updateQueue;if(n=n!==null?n.lastEffect:null,n!==null){var a=n=n.next;do{if((a.tag&e)===e){var o=a.destroy;a.destroy=void 0,o!==void 0&&px(t,r,o)}a=a.next}while(a!==n)}}function fg(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var r=t=t.next;do{if((r.tag&e)===e){var n=r.create;r.destroy=n()}r=r.next}while(r!==t)}}function hx(e){var t=e.ref;if(t!==null){var r=e.stateNode;switch(e.tag){case 5:e=r;break;default:e=r}typeof t=="function"?t(e):t.current=e}}function WN(e){var t=e.alternate;t!==null&&(e.alternate=null,WN(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ua],delete t[dd],delete t[ex],delete t[YL],delete t[VL])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function HN(e){return e.tag===5||e.tag===3||e.tag===4}function TC(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||HN(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function mx(e,t,r){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?r.nodeType===8?r.parentNode.insertBefore(e,t):r.insertBefore(e,t):(r.nodeType===8?(t=r.parentNode,t.insertBefore(e,r)):(t=r,t.appendChild(e)),r=r._reactRootContainer,r!=null||t.onclick!==null||(t.onclick=Sh));else if(n!==4&&(e=e.child,e!==null))for(mx(e,t,r),e=e.sibling;e!==null;)mx(e,t,r),e=e.sibling}function gx(e,t,r){var n=e.tag;if(n===5||n===6)e=e.stateNode,t?r.insertBefore(e,t):r.appendChild(e);else if(n!==4&&(e=e.child,e!==null))for(gx(e,t,r),e=e.sibling;e!==null;)gx(e,t,r),e=e.sibling}var xr=null,pa=!1;function vi(e,t,r){for(r=r.child;r!==null;)UN(e,t,r),r=r.sibling}function UN(e,t,r){if(eo&&typeof eo.onCommitFiberUnmount=="function")try{eo.onCommitFiberUnmount(ag,r)}catch{}switch(r.tag){case 5:Tr||Nl(r,t);case 6:var n=xr,a=pa;xr=null,vi(e,t,r),xr=n,pa=a,xr!==null&&(pa?(e=xr,r=r.stateNode,e.nodeType===8?e.parentNode.removeChild(r):e.removeChild(r)):xr.removeChild(r.stateNode));break;case 18:xr!==null&&(pa?(e=xr,r=r.stateNode,e.nodeType===8?Py(e.parentNode,r):e.nodeType===1&&Py(e,r),id(e)):Py(xr,r.stateNode));break;case 4:n=xr,a=pa,xr=r.stateNode.containerInfo,pa=!0,vi(e,t,r),xr=n,pa=a;break;case 0:case 11:case 14:case 15:if(!Tr&&(n=r.updateQueue,n!==null&&(n=n.lastEffect,n!==null))){a=n=n.next;do{var o=a,i=o.destroy;o=o.tag,i!==void 0&&(o&2||o&4)&&px(r,t,i),a=a.next}while(a!==n)}vi(e,t,r);break;case 1:if(!Tr&&(Nl(r,t),n=r.stateNode,typeof n.componentWillUnmount=="function"))try{n.props=r.memoizedProps,n.state=r.memoizedState,n.componentWillUnmount()}catch(s){Bt(r,t,s)}vi(e,t,r);break;case 21:vi(e,t,r);break;case 22:r.mode&1?(Tr=(n=Tr)||r.memoizedState!==null,vi(e,t,r),Tr=n):vi(e,t,r);break;default:vi(e,t,r)}}function jC(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var r=e.stateNode;r===null&&(r=e.stateNode=new l3),t.forEach(function(n){var a=y3.bind(null,e,n);r.has(n)||(r.add(n),n.then(a,a))})}}function ua(e,t){var r=t.deletions;if(r!==null)for(var n=0;na&&(a=i),n&=~o}if(n=a,n=Yt()-n,n=(120>n?120:480>n?480:1080>n?1080:1920>n?1920:3e3>n?3e3:4320>n?4320:1960*d3(n/1960))-n,10e?16:e,Di===null)var n=!1;else{if(e=Di,Di=null,Rh=0,et&6)throw Error(se(331));var a=et;for(et|=4,be=e.current;be!==null;){var o=be,i=o.child;if(be.flags&16){var s=o.deletions;if(s!==null){for(var l=0;lYt()-Aw?Ds(e,0):jw|=r),rn(e,t)}function ZN(e,t){t===0&&(e.mode&1?(t=Ff,Ff<<=1,!(Ff&130023424)&&(Ff=4194304)):t=1);var r=Hr();e=Fo(e,t),e!==null&&(Vd(e,t,r),rn(e,r))}function v3(e){var t=e.memoizedState,r=0;t!==null&&(r=t.retryLane),ZN(e,r)}function y3(e,t){var r=0;switch(e.tag){case 13:var n=e.stateNode,a=e.memoizedState;a!==null&&(r=a.retryLane);break;case 19:n=e.stateNode;break;default:throw Error(se(314))}n!==null&&n.delete(t),ZN(e,r)}var JN;JN=function(e,t,r){if(e!==null)if(e.memoizedProps!==t.pendingProps||en.current)Zr=!0;else{if(!(e.lanes&r)&&!(t.flags&128))return Zr=!1,o3(e,t,r);Zr=!!(e.flags&131072)}else Zr=!1,Dt&&t.flags&1048576&&nN(t,Eh,t.index);switch(t.lanes=0,t.tag){case 2:var n=t.type;Hp(e,t),e=t.pendingProps;var a=Xl(t,$r.current);Fl(t,r),a=Ow(null,t,n,e,a,r);var o=Dw();return t.flags|=1,typeof a=="object"&&a!==null&&typeof a.render=="function"&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,tn(n)?(o=!0,Ch(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Sw(t),a.updater=dg,t.stateNode=a,a._reactInternals=t,ix(t,n,e,r),t=cx(null,t,n,!0,o,r)):(t.tag=0,Dt&&o&&mw(t),Lr(null,t,a,r),t=t.child),t;case 16:n=t.elementType;e:{switch(Hp(e,t),e=t.pendingProps,a=n._init,n=a(n._payload),t.type=n,a=t.tag=b3(n),e=fa(n,e),a){case 0:t=lx(null,t,n,e,r);break e;case 1:t=DC(null,t,n,e,r);break e;case 11:t=EC(null,t,n,e,r);break e;case 14:t=OC(null,t,n,fa(n.type,e),r);break e}throw Error(se(306,n,""))}return t;case 0:return n=t.type,a=t.pendingProps,a=t.elementType===n?a:fa(n,a),lx(e,t,n,a,r);case 1:return n=t.type,a=t.pendingProps,a=t.elementType===n?a:fa(n,a),DC(e,t,n,a,r);case 3:e:{if(IN(t),e===null)throw Error(se(387));n=t.pendingProps,o=t.memoizedState,a=o.element,cN(e,t),_h(t,n,null,r);var i=t.memoizedState;if(n=i.element,o.isDehydrated)if(o={element:n,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=ec(Error(se(423)),t),t=_C(e,t,n,r,a);break e}else if(n!==a){a=ec(Error(se(424)),t),t=_C(e,t,n,r,a);break e}else for(wn=ji(t.stateNode.containerInfo.firstChild),kn=t,Dt=!0,ma=null,r=sN(t,null,n,r),t.child=r;r;)r.flags=r.flags&-3|4096,r=r.sibling;else{if(Ql(),n===a){t=zo(e,t,r);break e}Lr(e,t,n,r)}t=t.child}return t;case 5:return uN(t),e===null&&nx(t),n=t.type,a=t.pendingProps,o=e!==null?e.memoizedProps:null,i=a.children,Z0(n,a)?i=null:o!==null&&Z0(n,o)&&(t.flags|=32),RN(e,t),Lr(e,t,i,r),t.child;case 6:return e===null&&nx(t),null;case 13:return $N(e,t,r);case 4:return kw(t,t.stateNode.containerInfo),n=t.pendingProps,e===null?t.child=Zl(t,null,n,r):Lr(e,t,n,r),t.child;case 11:return n=t.type,a=t.pendingProps,a=t.elementType===n?a:fa(n,a),EC(e,t,n,a,r);case 7:return Lr(e,t,t.pendingProps,r),t.child;case 8:return Lr(e,t,t.pendingProps.children,r),t.child;case 12:return Lr(e,t,t.pendingProps.children,r),t.child;case 10:e:{if(n=t.type._context,a=t.pendingProps,o=t.memoizedProps,i=a.value,bt(Oh,n._currentValue),n._currentValue=i,o!==null)if(Da(o.value,i)){if(o.children===a.children&&!en.current){t=zo(e,t,r);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var s=o.dependencies;if(s!==null){i=o.child;for(var l=s.firstContext;l!==null;){if(l.context===n){if(o.tag===1){l=No(-1,r&-r),l.tag=2;var c=o.updateQueue;if(c!==null){c=c.shared;var u=c.pending;u===null?l.next=l:(l.next=u.next,u.next=l),c.pending=l}}o.lanes|=r,l=o.alternate,l!==null&&(l.lanes|=r),ax(o.return,r,t),s.lanes|=r;break}l=l.next}}else if(o.tag===10)i=o.type===t.type?null:o.child;else if(o.tag===18){if(i=o.return,i===null)throw Error(se(341));i.lanes|=r,s=i.alternate,s!==null&&(s.lanes|=r),ax(i,r,t),i=o.sibling}else i=o.child;if(i!==null)i.return=o;else for(i=o;i!==null;){if(i===t){i=null;break}if(o=i.sibling,o!==null){o.return=i.return,i=o;break}i=i.return}o=i}Lr(e,t,a.children,r),t=t.child}return t;case 9:return a=t.type,n=t.pendingProps.children,Fl(t,r),a=Gn(a),n=n(a),t.flags|=1,Lr(e,t,n,r),t.child;case 14:return n=t.type,a=fa(n,t.pendingProps),a=fa(n.type,a),OC(e,t,n,a,r);case 15:return jN(e,t,t.type,t.pendingProps,r);case 17:return n=t.type,a=t.pendingProps,a=t.elementType===n?a:fa(n,a),Hp(e,t),t.tag=1,tn(n)?(e=!0,Ch(t)):e=!1,Fl(t,r),NN(t,n,a),ix(t,n,a,r),cx(null,t,n,!0,e,r);case 19:return LN(e,t,r);case 22:return AN(e,t,r)}throw Error(se(156,t.tag))};function eM(e,t){return O_(e,t)}function x3(e,t,r,n){this.tag=e,this.key=r,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=n,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Hn(e,t,r,n){return new x3(e,t,r,n)}function Lw(e){return e=e.prototype,!(!e||!e.isReactComponent)}function b3(e){if(typeof e=="function")return Lw(e)?1:0;if(e!=null){if(e=e.$$typeof,e===nw)return 11;if(e===aw)return 14}return 2}function $i(e,t){var r=e.alternate;return r===null?(r=Hn(e.tag,t,e.key,e.mode),r.elementType=e.elementType,r.type=e.type,r.stateNode=e.stateNode,r.alternate=e,e.alternate=r):(r.pendingProps=t,r.type=e.type,r.flags=0,r.subtreeFlags=0,r.deletions=null),r.flags=e.flags&14680064,r.childLanes=e.childLanes,r.lanes=e.lanes,r.child=e.child,r.memoizedProps=e.memoizedProps,r.memoizedState=e.memoizedState,r.updateQueue=e.updateQueue,t=e.dependencies,r.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},r.sibling=e.sibling,r.index=e.index,r.ref=e.ref,r}function Vp(e,t,r,n,a,o){var i=2;if(n=e,typeof e=="function")Lw(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case wl:return _s(r.children,a,o,t);case rw:i=8,a|=8;break;case N0:return e=Hn(12,r,t,a|2),e.elementType=N0,e.lanes=o,e;case M0:return e=Hn(13,r,t,a),e.elementType=M0,e.lanes=o,e;case T0:return e=Hn(19,r,t,a),e.elementType=T0,e.lanes=o,e;case u_:return hg(r,a,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case l_:i=10;break e;case c_:i=9;break e;case nw:i=11;break e;case aw:i=14;break e;case xi:i=16,n=null;break e}throw Error(se(130,e==null?e:typeof e,""))}return t=Hn(i,r,t,a),t.elementType=e,t.type=n,t.lanes=o,t}function _s(e,t,r,n){return e=Hn(7,e,n,t),e.lanes=r,e}function hg(e,t,r,n){return e=Hn(22,e,n,t),e.elementType=u_,e.lanes=r,e.stateNode={isHidden:!1},e}function jy(e,t,r){return e=Hn(6,e,null,t),e.lanes=r,e}function Ay(e,t,r){return t=Hn(4,e.children!==null?e.children:[],e.key,t),t.lanes=r,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function w3(e,t,r,n,a){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=hy(0),this.expirationTimes=hy(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=hy(0),this.identifierPrefix=n,this.onRecoverableError=a,this.mutableSourceEagerHydrationData=null}function Fw(e,t,r,n,a,o,i,s,l){return e=new w3(e,t,r,s,l),t===1?(t=1,o===!0&&(t|=8)):t=0,o=Hn(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:n,isDehydrated:r,cache:null,transitions:null,pendingSuspenseBoundaries:null},Sw(o),e}function S3(e,t,r){var n=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(aM)}catch(e){console.error(e)}}aM(),a_.exports=Nn;var Nc=a_.exports;const Pu=ti(Nc);var BC=Nc;D0.createRoot=BC.createRoot,D0.hydrateRoot=BC.hydrateRoot;/** + * react-router v7.10.1 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var WC="popstate";function O3(e={}){function t(n,a){let{pathname:o,search:i,hash:s}=n.location;return wx("",{pathname:o,search:i,hash:s},a.state&&a.state.usr||null,a.state&&a.state.key||"default")}function r(n,a){return typeof a=="string"?a:yd(a)}return _3(t,r,null,e)}function Nt(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function Qn(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function D3(){return Math.random().toString(36).substring(2,10)}function HC(e,t){return{usr:e.state,key:e.key,idx:t}}function wx(e,t,r=null,n){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?Mc(t):t,state:r,key:t&&t.key||n||D3()}}function yd({pathname:e="/",search:t="",hash:r=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),r&&r!=="#"&&(e+=r.charAt(0)==="#"?r:"#"+r),e}function Mc(e){let t={};if(e){let r=e.indexOf("#");r>=0&&(t.hash=e.substring(r),e=e.substring(0,r));let n=e.indexOf("?");n>=0&&(t.search=e.substring(n),e=e.substring(0,n)),e&&(t.pathname=e)}return t}function _3(e,t,r,n={}){let{window:a=document.defaultView,v5Compat:o=!1}=n,i=a.history,s="POP",l=null,c=u();c==null&&(c=0,i.replaceState({...i.state,idx:c},""));function u(){return(i.state||{idx:null}).idx}function d(){s="POP";let v=u(),x=v==null?null:v-c;c=v,l&&l({action:s,location:y.location,delta:x})}function f(v,x){s="PUSH";let w=wx(y.location,v,x);c=u()+1;let b=HC(w,c),S=y.createHref(w);try{i.pushState(b,"",S)}catch(k){if(k instanceof DOMException&&k.name==="DataCloneError")throw k;a.location.assign(S)}o&&l&&l({action:s,location:y.location,delta:1})}function p(v,x){s="REPLACE";let w=wx(y.location,v,x);c=u();let b=HC(w,c),S=y.createHref(w);i.replaceState(b,"",S),o&&l&&l({action:s,location:y.location,delta:0})}function g(v){return N3(v)}let y={get action(){return s},get location(){return e(a,i)},listen(v){if(l)throw new Error("A history only accepts one active listener");return a.addEventListener(WC,d),l=v,()=>{a.removeEventListener(WC,d),l=null}},createHref(v){return t(a,v)},createURL:g,encodeLocation(v){let x=g(v);return{pathname:x.pathname,search:x.search,hash:x.hash}},push:f,replace:p,go(v){return i.go(v)}};return y}function N3(e,t=!1){let r="http://localhost";typeof window<"u"&&(r=window.location.origin!=="null"?window.location.origin:window.location.href),Nt(r,"No window.location.(origin|href) available to create URL");let n=typeof e=="string"?e:yd(e);return n=n.replace(/ $/,"%20"),!t&&n.startsWith("//")&&(n=r+n),new URL(n,r)}function oM(e,t,r="/"){return M3(e,t,r,!1)}function M3(e,t,r,n){let a=typeof t=="string"?Mc(t):t,o=Bo(a.pathname||"/",r);if(o==null)return null;let i=iM(e);T3(i);let s=null;for(let l=0;s==null&&l{let u={relativePath:c===void 0?i.path||"":c,caseSensitive:i.caseSensitive===!0,childrenIndex:s,route:i};if(u.relativePath.startsWith("/")){if(!u.relativePath.startsWith(n)&&l)return;Nt(u.relativePath.startsWith(n),`Absolute route path "${u.relativePath}" nested under path "${n}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),u.relativePath=u.relativePath.slice(n.length)}let d=Mo([n,u.relativePath]),f=r.concat(u);i.children&&i.children.length>0&&(Nt(i.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${d}".`),iM(i.children,t,f,d,l)),!(i.path==null&&!i.index)&&t.push({path:d,score:F3(d,i.index),routesMeta:f})};return e.forEach((i,s)=>{var l;if(i.path===""||!((l=i.path)!=null&&l.includes("?")))o(i,s);else for(let c of sM(i.path))o(i,s,!0,c)}),t}function sM(e){let t=e.split("/");if(t.length===0)return[];let[r,...n]=t,a=r.endsWith("?"),o=r.replace(/\?$/,"");if(n.length===0)return a?[o,""]:[o];let i=sM(n.join("/")),s=[];return s.push(...i.map(l=>l===""?o:[o,l].join("/"))),a&&s.push(...i),s.map(l=>e.startsWith("/")&&l===""?"/":l)}function T3(e){e.sort((t,r)=>t.score!==r.score?r.score-t.score:z3(t.routesMeta.map(n=>n.childrenIndex),r.routesMeta.map(n=>n.childrenIndex)))}var j3=/^:[\w-]+$/,A3=3,R3=2,I3=1,$3=10,L3=-2,UC=e=>e==="*";function F3(e,t){let r=e.split("/"),n=r.length;return r.some(UC)&&(n+=L3),t&&(n+=R3),r.filter(a=>!UC(a)).reduce((a,o)=>a+(j3.test(o)?A3:o===""?I3:$3),n)}function z3(e,t){return e.length===t.length&&e.slice(0,-1).every((n,a)=>n===t[a])?e[e.length-1]-t[t.length-1]:0}function B3(e,t,r=!1){let{routesMeta:n}=e,a={},o="/",i=[];for(let s=0;s{if(u==="*"){let g=s[f]||"";i=o.slice(0,o.length-g.length).replace(/(.)\/+$/,"$1")}const p=s[f];return d&&!p?c[u]=void 0:c[u]=(p||"").replace(/%2F/g,"/"),c},{}),pathname:o,pathnameBase:i,pattern:e}}function W3(e,t=!1,r=!0){Qn(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let n=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(i,s,l)=>(n.push({paramName:s,isOptional:l!=null}),l?"/?([^\\/]+)?":"/([^\\/]+)")).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(n.push({paramName:"*"}),a+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):r?a+="\\/*$":e!==""&&e!=="/"&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),n]}function H3(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return Qn(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function Bo(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let r=t.endsWith("/")?t.length-1:t.length,n=e.charAt(r);return n&&n!=="/"?null:e.slice(r)||"/"}var U3=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Y3=e=>U3.test(e);function V3(e,t="/"){let{pathname:r,search:n="",hash:a=""}=typeof e=="string"?Mc(e):e,o;if(r)if(Y3(r))o=r;else{if(r.includes("//")){let i=r;r=r.replace(/\/\/+/g,"/"),Qn(!1,`Pathnames cannot have embedded double slashes - normalizing ${i} -> ${r}`)}r.startsWith("/")?o=YC(r.substring(1),"/"):o=YC(r,t)}else o=t;return{pathname:o,search:G3(n),hash:X3(a)}}function YC(e,t){let r=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(a=>{a===".."?r.length>1&&r.pop():a!=="."&&r.push(a)}),r.length>1?r.join("/"):"/"}function Ry(e,t,r,n){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(n)}]. Please separate it out to the \`to.${r}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function K3(e){return e.filter((t,r)=>r===0||t.route.path&&t.route.path.length>0)}function Hw(e){let t=K3(e);return t.map((r,n)=>n===t.length-1?r.pathname:r.pathnameBase)}function Uw(e,t,r,n=!1){let a;typeof e=="string"?a=Mc(e):(a={...e},Nt(!a.pathname||!a.pathname.includes("?"),Ry("?","pathname","search",a)),Nt(!a.pathname||!a.pathname.includes("#"),Ry("#","pathname","hash",a)),Nt(!a.search||!a.search.includes("#"),Ry("#","search","hash",a)));let o=e===""||a.pathname==="",i=o?"/":a.pathname,s;if(i==null)s=r;else{let d=t.length-1;if(!n&&i.startsWith("..")){let f=i.split("/");for(;f[0]==="..";)f.shift(),d-=1;a.pathname=f.join("/")}s=d>=0?t[d]:"/"}let l=V3(a,s),c=i&&i!=="/"&&i.endsWith("/"),u=(o||i===".")&&r.endsWith("/");return!l.pathname.endsWith("/")&&(c||u)&&(l.pathname+="/"),l}var Mo=e=>e.join("/").replace(/\/\/+/g,"/"),q3=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),G3=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,X3=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e;function Q3(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function Z3(e){return e.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var lM=["POST","PUT","PATCH","DELETE"];new Set(lM);var J3=["GET",...lM];new Set(J3);var Tc=m.createContext(null);Tc.displayName="DataRouter";var xg=m.createContext(null);xg.displayName="DataRouterState";m.createContext(!1);var cM=m.createContext({isTransitioning:!1});cM.displayName="ViewTransition";var e6=m.createContext(new Map);e6.displayName="Fetchers";var t6=m.createContext(null);t6.displayName="Await";var aa=m.createContext(null);aa.displayName="Navigation";var Xd=m.createContext(null);Xd.displayName="Location";var ja=m.createContext({outlet:null,matches:[],isDataRoute:!1});ja.displayName="Route";var Yw=m.createContext(null);Yw.displayName="RouteError";function r6(e,{relative:t}={}){Nt(jc(),"useHref() may be used only in the context of a component.");let{basename:r,navigator:n}=m.useContext(aa),{hash:a,pathname:o,search:i}=Qd(e,{relative:t}),s=o;return r!=="/"&&(s=o==="/"?r:Mo([r,o])),n.createHref({pathname:s,search:i,hash:a})}function jc(){return m.useContext(Xd)!=null}function Aa(){return Nt(jc(),"useLocation() may be used only in the context of a component."),m.useContext(Xd).location}var uM="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function dM(e){m.useContext(aa).static||m.useLayoutEffect(e)}function ni(){let{isDataRoute:e}=m.useContext(ja);return e?g6():n6()}function n6(){Nt(jc(),"useNavigate() may be used only in the context of a component.");let e=m.useContext(Tc),{basename:t,navigator:r}=m.useContext(aa),{matches:n}=m.useContext(ja),{pathname:a}=Aa(),o=JSON.stringify(Hw(n)),i=m.useRef(!1);return dM(()=>{i.current=!0}),m.useCallback((l,c={})=>{if(Qn(i.current,uM),!i.current)return;if(typeof l=="number"){r.go(l);return}let u=Uw(l,JSON.parse(o),a,c.relative==="path");e==null&&t!=="/"&&(u.pathname=u.pathname==="/"?t:Mo([t,u.pathname])),(c.replace?r.replace:r.push)(u,c.state,c)},[t,r,o,a,e])}m.createContext(null);function a6(){let{matches:e}=m.useContext(ja),t=e[e.length-1];return t?t.params:{}}function Qd(e,{relative:t}={}){let{matches:r}=m.useContext(ja),{pathname:n}=Aa(),a=JSON.stringify(Hw(r));return m.useMemo(()=>Uw(e,JSON.parse(a),n,t==="path"),[e,a,n,t])}function o6(e,t){return fM(e,t)}function fM(e,t,r,n,a){var w;Nt(jc(),"useRoutes() may be used only in the context of a component.");let{navigator:o}=m.useContext(aa),{matches:i}=m.useContext(ja),s=i[i.length-1],l=s?s.params:{},c=s?s.pathname:"/",u=s?s.pathnameBase:"/",d=s&&s.route;{let b=d&&d.path||"";pM(c,!d||b.endsWith("*")||b.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${c}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let f=Aa(),p;if(t){let b=typeof t=="string"?Mc(t):t;Nt(u==="/"||((w=b.pathname)==null?void 0:w.startsWith(u)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${u}" but pathname "${b.pathname}" was given in the \`location\` prop.`),p=b}else p=f;let g=p.pathname||"/",y=g;if(u!=="/"){let b=u.replace(/^\//,"").split("/");y="/"+g.replace(/^\//,"").split("/").slice(b.length).join("/")}let v=oM(e,{pathname:y});Qn(d||v!=null,`No routes matched location "${p.pathname}${p.search}${p.hash}" `),Qn(v==null||v[v.length-1].route.element!==void 0||v[v.length-1].route.Component!==void 0||v[v.length-1].route.lazy!==void 0,`Matched leaf route at location "${p.pathname}${p.search}${p.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let x=u6(v&&v.map(b=>Object.assign({},b,{params:Object.assign({},l,b.params),pathname:Mo([u,o.encodeLocation?o.encodeLocation(b.pathname.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:b.pathname]),pathnameBase:b.pathnameBase==="/"?u:Mo([u,o.encodeLocation?o.encodeLocation(b.pathnameBase.replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:b.pathnameBase])})),i,r,n,a);return t&&x?m.createElement(Xd.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",...p},navigationType:"POP"}},x):x}function i6(){let e=m6(),t=Q3(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,n="rgba(200,200,200, 0.5)",a={padding:"0.5rem",backgroundColor:n},o={padding:"2px 4px",backgroundColor:n},i=null;return console.error("Error handled by React Router default ErrorBoundary:",e),i=m.createElement(m.Fragment,null,m.createElement("p",null,"💿 Hey developer 👋"),m.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",m.createElement("code",{style:o},"ErrorBoundary")," or"," ",m.createElement("code",{style:o},"errorElement")," prop on your route.")),m.createElement(m.Fragment,null,m.createElement("h2",null,"Unexpected Application Error!"),m.createElement("h3",{style:{fontStyle:"italic"}},t),r?m.createElement("pre",{style:a},r):null,i)}var s6=m.createElement(i6,null),l6=class extends m.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){return this.state.error!==void 0?m.createElement(ja.Provider,{value:this.props.routeContext},m.createElement(Yw.Provider,{value:this.state.error,children:this.props.component})):this.props.children}};function c6({routeContext:e,match:t,children:r}){let n=m.useContext(Tc);return n&&n.static&&n.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(n.staticContext._deepestRenderedBoundaryId=t.route.id),m.createElement(ja.Provider,{value:e},r)}function u6(e,t=[],r=null,n=null,a=null){if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let o=e,i=r==null?void 0:r.errors;if(i!=null){let u=o.findIndex(d=>d.route.id&&(i==null?void 0:i[d.route.id])!==void 0);Nt(u>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(i).join(",")}`),o=o.slice(0,Math.min(o.length,u+1))}let s=!1,l=-1;if(r)for(let u=0;u=0?o=o.slice(0,l+1):o=[o[0]];break}}}let c=r&&n?(u,d)=>{var f,p;n(u,{location:r.location,params:((p=(f=r.matches)==null?void 0:f[0])==null?void 0:p.params)??{},unstable_pattern:Z3(r.matches),errorInfo:d})}:void 0;return o.reduceRight((u,d,f)=>{let p,g=!1,y=null,v=null;r&&(p=i&&d.route.id?i[d.route.id]:void 0,y=d.route.errorElement||s6,s&&(l<0&&f===0?(pM("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),g=!0,v=null):l===f&&(g=!0,v=d.route.hydrateFallbackElement||null)));let x=t.concat(o.slice(0,f+1)),w=()=>{let b;return p?b=y:g?b=v:d.route.Component?b=m.createElement(d.route.Component,null):d.route.element?b=d.route.element:b=u,m.createElement(c6,{match:d,routeContext:{outlet:u,matches:x,isDataRoute:r!=null},children:b})};return r&&(d.route.ErrorBoundary||d.route.errorElement||f===0)?m.createElement(l6,{location:r.location,revalidation:r.revalidation,component:y,error:p,children:w(),routeContext:{outlet:null,matches:x,isDataRoute:!0},onError:c}):w()},null)}function Vw(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function d6(e){let t=m.useContext(Tc);return Nt(t,Vw(e)),t}function f6(e){let t=m.useContext(xg);return Nt(t,Vw(e)),t}function p6(e){let t=m.useContext(ja);return Nt(t,Vw(e)),t}function Kw(e){let t=p6(e),r=t.matches[t.matches.length-1];return Nt(r.route.id,`${e} can only be used on routes that contain a unique "id"`),r.route.id}function h6(){return Kw("useRouteId")}function m6(){var n;let e=m.useContext(Yw),t=f6("useRouteError"),r=Kw("useRouteError");return e!==void 0?e:(n=t.errors)==null?void 0:n[r]}function g6(){let{router:e}=d6("useNavigate"),t=Kw("useNavigate"),r=m.useRef(!1);return dM(()=>{r.current=!0}),m.useCallback(async(a,o={})=>{Qn(r.current,uM),r.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...o}))},[e,t])}var VC={};function pM(e,t,r){!t&&!VC[e]&&(VC[e]=!0,Qn(!1,r))}m.memo(v6);function v6({routes:e,future:t,state:r,unstable_onError:n}){return fM(e,void 0,r,n,t)}function Yu({to:e,replace:t,state:r,relative:n}){Nt(jc()," may be used only in the context of a component.");let{static:a}=m.useContext(aa);Qn(!a," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:o}=m.useContext(ja),{pathname:i}=Aa(),s=ni(),l=Uw(e,Hw(o),i,n==="path"),c=JSON.stringify(l);return m.useEffect(()=>{s(JSON.parse(c),{replace:t,state:r,relative:n})},[s,c,n,t,r]),null}function ga(e){Nt(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function y6({basename:e="/",children:t=null,location:r,navigationType:n="POP",navigator:a,static:o=!1,unstable_useTransitions:i}){Nt(!jc(),"You cannot render a inside another . You should never have more than one in your app.");let s=e.replace(/^\/*/,"/"),l=m.useMemo(()=>({basename:s,navigator:a,static:o,unstable_useTransitions:i,future:{}}),[s,a,o,i]);typeof r=="string"&&(r=Mc(r));let{pathname:c="/",search:u="",hash:d="",state:f=null,key:p="default"}=r,g=m.useMemo(()=>{let y=Bo(c,s);return y==null?null:{location:{pathname:y,search:u,hash:d,state:f,key:p},navigationType:n}},[s,c,u,d,f,p,n]);return Qn(g!=null,` is not able to match the URL "${c}${u}${d}" because it does not start with the basename, so the won't render anything.`),g==null?null:m.createElement(aa.Provider,{value:l},m.createElement(Xd.Provider,{children:t,value:g}))}function hM({children:e,location:t}){return o6(Sx(e),t)}function Sx(e,t=[]){let r=[];return m.Children.forEach(e,(n,a)=>{if(!m.isValidElement(n))return;let o=[...t,a];if(n.type===m.Fragment){r.push.apply(r,Sx(n.props.children,o));return}Nt(n.type===ga,`[${typeof n.type=="string"?n.type:n.type.name}] is not a component. All component children of must be a or `),Nt(!n.props.index||!n.props.children,"An index route cannot have child routes.");let i={id:n.props.id||o.join("-"),caseSensitive:n.props.caseSensitive,element:n.props.element,Component:n.props.Component,index:n.props.index,path:n.props.path,middleware:n.props.middleware,loader:n.props.loader,action:n.props.action,hydrateFallbackElement:n.props.hydrateFallbackElement,HydrateFallback:n.props.HydrateFallback,errorElement:n.props.errorElement,ErrorBoundary:n.props.ErrorBoundary,hasErrorBoundary:n.props.hasErrorBoundary===!0||n.props.ErrorBoundary!=null||n.props.errorElement!=null,shouldRevalidate:n.props.shouldRevalidate,handle:n.props.handle,lazy:n.props.lazy};n.props.children&&(i.children=Sx(n.props.children,o)),r.push(i)}),r}var Kp="get",qp="application/x-www-form-urlencoded";function bg(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function x6(e){return bg(e)&&e.tagName.toLowerCase()==="button"}function b6(e){return bg(e)&&e.tagName.toLowerCase()==="form"}function w6(e){return bg(e)&&e.tagName.toLowerCase()==="input"}function S6(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function k6(e,t){return e.button===0&&(!t||t==="_self")&&!S6(e)}var Qf=null;function C6(){if(Qf===null)try{new FormData(document.createElement("form"),0),Qf=!1}catch{Qf=!0}return Qf}var P6=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Iy(e){return e!=null&&!P6.has(e)?(Qn(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${qp}"`),null):e}function E6(e,t){let r,n,a,o,i;if(b6(e)){let s=e.getAttribute("action");n=s?Bo(s,t):null,r=e.getAttribute("method")||Kp,a=Iy(e.getAttribute("enctype"))||qp,o=new FormData(e)}else if(x6(e)||w6(e)&&(e.type==="submit"||e.type==="image")){let s=e.form;if(s==null)throw new Error('Cannot submit a + + + + + )} + + {/* 입력 폼 */} +
+ {/* 받는사람 */} +
+ +
+
+ {recipients.map((email, index) => ( +
+ {email} + +
+ ))} + { + if (inputValue.trim() && filteredUsers.length > 0) { + setShowUserSuggestions(true); + } + }} + placeholder="이메일 주소 입력 (Tab으로 다음)" + /> +
+ + {/* 자동완성 드롭다운 */} + {showUserSuggestions && ( +
+ {filteredUsers.map((user, index) => ( +
{ + e.preventDefault(); // blur 이벤트 방지 + selectUser(user.email); + }} + onMouseEnter={() => setSelectedSuggestionIndex(index)} + className={`px-4 py-2 cursor-pointer flex items-center justify-between transition-colors ${ + selectedSuggestionIndex === index + ? 'bg-blue-100 text-blue-700' + : 'hover:bg-blue-50' + }`} + > +
+
{user.email}
+ {user.name &&
{user.name}
} +
+
+ ))} +
+ )} +
+
+ + {/* 제목 */} +
+ 제목 + setSubject(e.target.value)} + placeholder="메일 제목을 입력하세요" + /> +
+ + {/* 본문 */} +
setBody(e.currentTarget.innerHTML)} + onMouseUp={updateFormatState} + onKeyUp={updateFormatState} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + suppressContentEditableWarning + data-placeholder="내용을 입력하세요..." + style={{ + minHeight: '200px', + maxWidth: '100%', + wordWrap: 'break-word' + }} + /> + + {/* 드래그 오버레이 */} + {isDragging && ( +
+
+ +

파일을 여기에 놓으세요

+
+
+ )} + + {/* 첨부파일 목록 */} + {attachments.length > 0 && ( +
+
+ {attachments.map((file, index) => ( +
+ + {file.name} + ({(file.size / 1024).toFixed(1)}KB) + +
+ ))} +
+
+ )} +
+ + {/* 하단 영역 */} +
+
+ + +
+ + + setShowFormatBar(!showFormatBar)} sx={{ color: showFormatBar ? '#6366f1' : '#9ca3af', '&:hover': { color: '#6366f1', backgroundColor: '#eef2ff' } }}> + + + + + + + + + + + + + +
+
+ + {/* 서식 툴바 */} + {showFormatBar && ( +
+ + applyFormat('bold')} + sx={{ + color: activeFormats.bold ? '#6366f1' : '#6b7280', + backgroundColor: activeFormats.bold ? '#eef2ff' : 'transparent', + '&:hover': { backgroundColor: '#f3f4f6' } + }} + > + + + + + applyFormat('italic')} + sx={{ + color: activeFormats.italic ? '#6366f1' : '#6b7280', + backgroundColor: activeFormats.italic ? '#eef2ff' : 'transparent', + '&:hover': { backgroundColor: '#f3f4f6' } + }} + > + + + + + applyFormat('underline')} + sx={{ + color: activeFormats.underline ? '#6366f1' : '#6b7280', + backgroundColor: activeFormats.underline ? '#eef2ff' : 'transparent', + '&:hover': { backgroundColor: '#f3f4f6' } + }} + > + + + +
+ )} +
+
+ + ); +}; + +export default ComposeModal; diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..136fd1d --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,107 @@ +/** + * 확인 다이얼로그 컴포넌트 + * 삭제, 스팸 신고, 별표 등 중요한 액션 전에 확인 요청 + */ +import React from 'react'; +import { AlertTriangle, Trash2, AlertOctagon, Star, RotateCcw } from 'lucide-react'; + +const ConfirmDialog = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = '확인', + cancelText = '취소', + type = 'warning' // warning, danger, info +}) => { + if (!isOpen) return null; + + // 타입별 아이콘과 색상 + const typeConfig = { + warning: { + icon: AlertTriangle, + bgColor: 'bg-amber-100', + iconColor: 'text-amber-600', + buttonColor: 'bg-amber-500 hover:bg-amber-600' + }, + danger: { + icon: Trash2, + bgColor: 'bg-red-100', + iconColor: 'text-red-600', + buttonColor: 'bg-red-500 hover:bg-red-600' + }, + spam: { + icon: AlertOctagon, + bgColor: 'bg-orange-100', + iconColor: 'text-orange-600', + buttonColor: 'bg-orange-500 hover:bg-orange-600' + }, + star: { + icon: Star, + bgColor: 'bg-amber-100', + iconColor: 'text-amber-500', + buttonColor: 'bg-amber-500 hover:bg-amber-600' + }, + restore: { + icon: RotateCcw, + bgColor: 'bg-emerald-100', + iconColor: 'text-emerald-600', + buttonColor: 'bg-emerald-500 hover:bg-emerald-600' + } + }; + + const config = typeConfig[type] || typeConfig.warning; + const Icon = config.icon; + + return ( +
+ {/* 배경 오버레이 */} +
+ + {/* 다이얼로그 */} +
+
+ {/* 아이콘 */} +
+ +
+ + {/* 제목 */} +

+ {title} +

+ + {/* 메시지 */} +

+ {message} +

+ + {/* 버튼 */} +
+ + +
+
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..560d660 --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { useMail } from '../context/MailContext'; +import Header from './Header'; +import Sidebar from './Sidebar'; +import MailList from './MailList'; +import MailDetail from './MailDetail'; +import ComposeModal from './ComposeModal'; +import { useParams, useNavigate } from 'react-router-dom'; +import { decodeEmailId } from '../utils/emailIdEncoder'; +import toast from 'react-hot-toast'; + +/** + * 대시보드 메인 컴포넌트 + * 사이드바, 메일 목록, 메일 상세, 메일 작성 모달 레이아웃 + */ +const Dashboard = () => { + const { fetchMailboxes, fetchEmails, emails, selectEmail, setSelectedEmail, selectedEmail, selectedBox } = useMail(); + const { mailbox, emailId } = useParams(); + const navigate = useNavigate(); + const [isComposeOpen, setIsComposeOpen] = useState(false); + const [draftData, setDraftData] = useState(null); + const [composeMode, setComposeMode] = useState('compose'); // compose, reply, forward + const [replyData, setReplyData] = useState(null); + + // 이전 selectedEmail 추적 (변경 감지용) + const prevSelectedEmailRef = useRef(selectedEmail); + + // 초기 데이터 로드 + useEffect(() => { + fetchMailboxes(); + + // 관리자 권한 거부 메시지 표시 (리다이렉트 후) + const deniedMessage = sessionStorage.getItem('admin_denied_message'); + if (deniedMessage) { + toast.error(deniedMessage, { duration: 4000, icon: '🚫' }); + sessionStorage.removeItem('admin_denied_message'); + } + + // 새로고침 감지: emailId가 있는 URL로 직접 접근/새로고침 시 메일함 초기 화면으로 리다이렉트 + // (읽지 않음으로 표시 후 새로고침해도 자동 읽음 처리 방지) + const navType = performance.getEntriesByType('navigation')[0]?.type; + const isReloadOrDirect = navType === 'reload' || navType === 'navigate'; + + if (emailId && isReloadOrDirect) { + const currentMailbox = mailbox || 'inbox'; + navigate(`/mail/${currentMailbox}`, { replace: true }); + } + }, []); + + // 메일함 변경 시 목록 조회 + useEffect(() => { + if (mailbox) { + fetchEmails(mailbox); + } + }, [mailbox]); + + // URL의 emailId로 메일 선택 (새로고침이 아닌 일반 네비게이션에서만) + useEffect(() => { + if (emailId) { + // 난독화된 ID 디코딩 + const decodedId = decodeEmailId(emailId); + if (decodedId) { + const found = emails.find(e => e.id === decodedId); + if (found && (!selectedEmail || selectedEmail.id !== found.id)) { + selectEmail(found); + } + } + } else { + setSelectedEmail(null); + } + }, [emailId, emails, selectedEmail]); + + // selectedEmail이 null이 되면 URL에서 emailId 제거 + useEffect(() => { + const prevEmail = prevSelectedEmailRef.current; + + // 이전에 선택된 메일이 있었는데 이제 null이 되면 URL 업데이트 + if (prevEmail && !selectedEmail && emailId) { + const currentMailbox = mailbox || selectedBox?.toLowerCase() || 'inbox'; + navigate(`/mail/${currentMailbox}`, { replace: true }); + } + + prevSelectedEmailRef.current = selectedEmail; + }, [selectedEmail, emailId, mailbox, selectedBox, navigate]); + + // 새 메일 작성 + const handleComposeClick = () => { + setDraftData(null); + setComposeMode('compose'); + setReplyData(null); + setIsComposeOpen(true); + }; + + // 모달 닫기 - 임시보관함이면 목록 새로고침 + const handleCloseCompose = () => { + setIsComposeOpen(false); + const hadDraftData = draftData !== null; + setDraftData(null); + setComposeMode('compose'); + setReplyData(null); + + // 임시보관함에서 닫으면 목록 새로고침 (임시저장 반영) + if (hadDraftData || mailbox?.toUpperCase() === 'DRAFTS') { + fetchEmails('DRAFTS'); + } + }; + + // 임시저장 이어서 작성 + const handleContinueDraft = (email) => { + setDraftData(email); + setComposeMode('compose'); + setReplyData(null); + setIsComposeOpen(true); + }; + + // 답장 + const handleReply = (email) => { + setDraftData(null); + setComposeMode('reply'); + setReplyData(email); + setIsComposeOpen(true); + }; + + // 전달 + const handleForward = (email) => { + setDraftData(null); + setComposeMode('forward'); + setReplyData(email); + setIsComposeOpen(true); + }; + + const activeBox = mailbox ? mailbox.toUpperCase() : 'INBOX'; + + return ( +
+ {/* 사이드바 */} +
+ +
+ + {/* 메인 콘텐츠 */} +
+
+
+ {/* 메일 목록 */} +
+ +
+ + {/* 구분선 */} +
+ + {/* 메일 상세 */} +
+ +
+
+
+ + {/* 메일 작성 모달 */} + +
+ ); +}; + +export default Dashboard; diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx new file mode 100644 index 0000000..b702296 --- /dev/null +++ b/frontend/src/components/Header.jsx @@ -0,0 +1,671 @@ +/** + * 헤더 컴포넌트 + * 검색바, 검색 필터, 프로필 메뉴, 관리자 버튼 + */ +import React, { useState, useRef, useEffect } from 'react'; +import { useMail } from '../context/MailContext'; +import { Search, SlidersHorizontal, ChevronDown, Check, Calendar as CalendarIcon, Settings, LogOut, User, Shield, X, Clock, Trash2 } from 'lucide-react'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import Fade from '@mui/material/Fade'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import "react-datepicker/dist/react-datepicker.css"; +import "./datepicker-custom.css"; +import ko from 'date-fns/locale/ko'; +import { Menu, Avatar } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +registerLocale('ko', ko); + +// 옵션 상수 +const sizeOperatorOptions = [ + { label: '초과', value: 'greater' }, + { label: '미만', value: 'less' } +]; + +const sizeUnitOptions = [ + { label: 'MB', value: 'MB' }, + { label: 'KB', value: 'KB' }, + { label: 'Bytes', value: 'B' } +]; + +const dateWithinOptions = [ + { label: '전체 기간', value: '' }, + { label: '1일', value: '1d' }, + { label: '1주', value: '1w' }, + { label: '1개월', value: '1m' }, + { label: '6개월', value: '6m' }, + { label: '1년', value: '1y' }, + { label: '직접 입력', value: 'custom' }, +]; + +const scopeOptions = [ + { label: '전체보관함', value: 'all' }, + { label: '받은편지함', value: 'inbox' }, + { label: '보낸편지함', value: 'sent' }, + { label: '중요편지함', value: 'important' }, + { label: '임시보관함', value: 'drafts' }, + { label: '스팸함', value: 'spam' }, + { label: '휴지통', value: 'trash' }, +]; + +const getLabel = (options, value) => options.find(o => o.value === value)?.label || ''; + +const CustomDateInput = React.forwardRef(({ value, onClick, onChange, placeholder }, ref) => ( +
+ + +
+)); + +const AnimatedDropdown = ({ isOpen, options, value, onChange, onClose }) => { + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + if (isOpen) setShouldRender(true); + else { + const timer = setTimeout(() => setShouldRender(false), 200); + return () => clearTimeout(timer); + } + }, [isOpen]); + + if (!shouldRender) return null; + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +}; + +const Header = () => { + const navigate = useNavigate(); + const { user, logout, searchEmails, clearSearch, isSearchMode, searchScope: currentScope, searchHistory, removeSearchHistory, clearSearchHistory } = useMail(); + const [showFilter, setShowFilter] = useState(false); + const [isFilterVisible, setIsFilterVisible] = useState(false); + const filterRef = useRef(null); + const [anchorEl, setAnchorEl] = useState(null); + const openMenu = Boolean(anchorEl); + + // 간단 검색용 상태 + const [simpleSearchQuery, setSimpleSearchQuery] = useState(''); + + const [searchParams, setSearchParams] = useState({ + from: '', to: '', subject: '', includes: '', excludes: '', + sizeOperator: 'greater', sizeValue: '', sizeUnit: 'MB', + dateWithin: '', dateReference: '', scope: 'all', + hasAttachment: false + }); + const [activeDropdown, setActiveDropdown] = useState(null); + + // 검색 히스토리 드롭다운 상태 + const [showHistory, setShowHistory] = useState(false); + const [historyIndex, setHistoryIndex] = useState(-1); + const searchInputRef = useRef(null); + + // 간단 검색 실행 (Enter 또는 버튼) + const handleSimpleSearch = (query = simpleSearchQuery) => { + if (query.trim()) { + searchEmails(query.trim(), searchParams.scope.toUpperCase()); + setShowFilter(false); + setShowHistory(false); + setHistoryIndex(-1); + } + }; + + // 상세 검색 필터 적용 + const handleAdvancedSearch = () => { + const query = simpleSearchQuery.trim(); + + // 필터 객체 구성 + const filters = {}; + + if (searchParams.from) filters.from = searchParams.from; + if (searchParams.to) filters.to = searchParams.to; + if (searchParams.subject) filters.subject = searchParams.subject; + if (searchParams.includes) filters.includes = searchParams.includes; + if (searchParams.excludes) filters.excludes = searchParams.excludes; + if (searchParams.hasAttachment) filters.hasAttachment = true; + + // 크기 필터 (bytes로 변환) + if (searchParams.sizeValue) { + const sizeBytes = parseFloat(searchParams.sizeValue) * + (searchParams.sizeUnit === 'MB' ? 1024 * 1024 : + searchParams.sizeUnit === 'KB' ? 1024 : 1); + + if (searchParams.sizeOperator === 'greater') { + filters.minSize = Math.floor(sizeBytes); + } else { + filters.maxSize = Math.floor(sizeBytes); + } + } + + // 날짜 필터 + if (searchParams.dateWithin && searchParams.dateWithin !== 'custom') { + const now = new Date(); + let dateAfter = new Date(); + + switch (searchParams.dateWithin) { + case '1d': dateAfter.setDate(now.getDate() - 1); break; + case '1w': dateAfter.setDate(now.getDate() - 7); break; + case '1m': dateAfter.setMonth(now.getMonth() - 1); break; + case '6m': dateAfter.setMonth(now.getMonth() - 6); break; + case '1y': dateAfter.setFullYear(now.getFullYear() - 1); break; + default: dateAfter = null; + } + + if (dateAfter) { + filters.dateAfter = dateAfter.toISOString().split('T')[0]; + } + } else if (searchParams.dateReference) { + // 직접 입력된 날짜 + filters.dateBefore = searchParams.dateReference; + } + + // 검색어나 필터 중 하나라도 있으면 검색 실행 + const hasFilters = Object.keys(filters).length > 0; + if (query || hasFilters) { + searchEmails(query, searchParams.scope.toUpperCase(), filters); + setShowFilter(false); + } + }; + + // 간단 검색 Enter 키 핸들러 (키보드 탐색 포함) + const handleSearchKeyDown = (e) => { + // 히스토리 키보드 탐색 + if (showHistory && searchHistory.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHistoryIndex(prev => + prev < searchHistory.length - 1 ? prev + 1 : 0 + ); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHistoryIndex(prev => + prev > 0 ? prev - 1 : searchHistory.length - 1 + ); + return; + } + if (e.key === 'Enter' && historyIndex >= 0) { + e.preventDefault(); + const selectedQuery = searchHistory[historyIndex]; + setSimpleSearchQuery(selectedQuery); + handleSimpleSearch(selectedQuery); + return; + } + } + + if (e.key === 'Enter') { + handleSimpleSearch(); + } else if (e.key === 'Escape') { + setSimpleSearchQuery(''); + setShowHistory(false); + clearSearch(); + } + }; + + // 히스토리 항목 클릭 + const handleHistoryClick = (query, e) => { + e.preventDefault(); + e.stopPropagation(); + setSimpleSearchQuery(query); + handleSimpleSearch(query); + }; + + // 히스토리 항목 삭제 + const handleHistoryDelete = (index, e) => { + e.preventDefault(); + e.stopPropagation(); + removeSearchHistory(index); + }; + + // 검색 모드 해제 시 입력값 초기화 + useEffect(() => { + if (!isSearchMode) { + setSimpleSearchQuery(''); + setHistoryIndex(-1); + } + }, [isSearchMode]); + + useEffect(() => { + const handleClickOutside = (event) => { + if (filterRef.current && !filterRef.current.contains(event.target)) { + setShowFilter(false); + setActiveDropdown(null); + setShowHistory(false); + setHistoryIndex(-1); + } + }; + if (showFilter || showHistory) document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showFilter, showHistory]); + + const [calendarOpen, setCalendarOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const closeCalendar = () => { + setIsClosing(true); + setTimeout(() => { setCalendarOpen(false); setIsClosing(false); }, 200); + }; + + const toggleCalendar = () => { + if (calendarOpen || isClosing) { + if (!isClosing) closeCalendar(); + } else { + setCalendarOpen(true); + } + }; + + useEffect(() => { + if (showFilter) setIsFilterVisible(true); + else { + const timer = setTimeout(() => setIsFilterVisible(false), 200); + return () => clearTimeout(timer); + } + }, [showFilter]); + + const handleInputChange = (e) => { + const { name, value, type, checked } = e.target; + setSearchParams(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); + }; + + const handleReset = () => { + setSearchParams({ + from: '', to: '', subject: '', includes: '', excludes: '', + sizeOperator: 'greater', sizeValue: '', sizeUnit: 'MB', + dateWithin: '', dateReference: '', scope: 'all', + hasAttachment: false + }); + setActiveDropdown(null); + setSimpleSearchQuery(''); + clearSearch(); + }; + + // 날짜 기간 선택 시 처리 + const handleDateWithinChange = (value) => { + setSearchParams(prev => ({ + ...prev, + dateWithin: value, + // 직접입력이 아니면 dateReference 초기화 + dateReference: value === 'custom' ? prev.dateReference : '' + })); + }; + + // 날짜 직접 선택 시 기간을 "직접 입력"으로 변경 + const handleDateReferenceChange = (date) => { + const formatted = date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().split('T')[0] : ''; + setSearchParams(prev => ({ + ...prev, + dateReference: formatted, + dateWithin: formatted ? 'custom' : prev.dateWithin + })); + closeCalendar(); + }; + + const handleProfileClick = (event) => setAnchorEl(event.currentTarget); + const handleProfileClose = () => setAnchorEl(null); + const handleLogout = () => { handleProfileClose(); logout(); }; + + const inputStyle = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none transition-all"; + const labelStyle = "text-sm font-medium text-gray-600 mb-1.5"; + + return ( +
+ {/* 검색 바 */} +
+
+
+ +
+ setSimpleSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + onFocus={() => setShowHistory(true)} + className={`block w-full pl-12 pr-12 h-11 border rounded-xl leading-5 placeholder-gray-400 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 text-sm transition-all ${ + isSearchMode + ? 'border-blue-400 bg-blue-50' + : 'border-gray-200 bg-gray-50 focus:bg-white' + }`} + placeholder="메일 검색... (Enter로 검색)" + /> +
+ + + +
+ + {/* 검색 히스토리 드롭다운 */} + {showHistory && searchHistory.length > 0 && !showFilter && ( +
+
+
+ + + 최근 검색 + + +
+ {searchHistory.map((item, index) => ( +
handleHistoryClick(item, e)} + className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-all group ${ + historyIndex === index + ? 'bg-blue-50 text-blue-600' + : 'hover:bg-gray-50 text-gray-700' + }`} + > +
+ + {item} +
+ +
+ ))} +
+
+ )} + + {/* 검색 필터 드롭다운 */} + {isFilterVisible && ( +
activeDropdown && setActiveDropdown(null)} + > +

상세 검색

+ + {/* 기본 필드 - 2열 그리드 */} +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + {/* 크기 필터 */} +
+ +
+
+ + setSearchParams(prev => ({ ...prev, sizeOperator: val }))} + onClose={() => setActiveDropdown(null)} + /> +
+ +
+ + setSearchParams(prev => ({ ...prev, sizeUnit: val }))} + onClose={() => setActiveDropdown(null)} + /> +
+
+
+ + {/* 기간 필터 - Gmail 스타일 */} +
+ +
+
+ + setActiveDropdown(null)} + /> +
+
+ } + /> +
+
+
+ + {/* 검색 범위 */} +
+ +
+ + setSearchParams(prev => ({ ...prev, scope: val }))} + onClose={() => setActiveDropdown(null)} + /> +
+
+ + {/* 체크박스 옵션 */} +
+ } + label={첨부파일 있음} + /> +
+ + {/* 버튼 */} +
+ + +
+
+ )} +
+
+ + {/* 우측 아이콘 */} +
+ {/* 관리자 설정 버튼 */} + {user && user.isAdmin && ( + + + + )} + + {/* 프로필 버튼 */} + + + {/* 프로필 메뉴 - 개선된 디자인 */} + +
+ {/* 사용자 정보 카드 */} +
+ + {user?.name?.charAt(0)?.toUpperCase() || 'U'} + +
+
+

{user?.name || '사용자'}

+ {user?.isAdmin && ( + + + 관리자 + + )} +
+

{user?.email}

+
+
+ + {/* 로그아웃 버튼 */} + +
+
+
+
+ ); +}; + +export default Header; diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..768ccc3 --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,173 @@ +/** + * 로그인 페이지 컴포넌트 + * 현대적인 UI 디자인 적용 (그라디언트 배경, 글래스모피즘 카드) + */ +import React, { useState, useEffect } from 'react'; +import { useMail } from '../context/MailContext'; +import { Lock, User, Mail } from 'lucide-react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import Button from '@mui/material/Button'; + +const Login = () => { + const { login, error, user } = useMail(); + const navigate = useNavigate(); + const location = useLocation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const from = location.state?.from?.pathname || '/'; + + useEffect(() => { + if (user) { + // [보안/UX] Admin 페이지만 리다이렉트 유지, 그 외는 항상 홈으로 초기화 + if (from.startsWith('/admin')) { + navigate(from, { replace: true }); + } else { + navigate('/', { replace: true }); + } + } + }, [user, navigate, from]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + await login(email, password); + setIsLoading(false); + }; + + return ( +
+ {/* 그라디언트 배경 */} +
+ + {/* 배경 장식 요소 */} +
+
+
+ + {/* 메인 컨텐츠 */} +
+ {/* 로고 및 타이틀 */} +
+
+ +
+

+ Mailbox +

+

+ 안전한 이메일 서비스 +

+
+ + {/* 로그인 카드 */} +
+ + {/* 아이디 입력 */} +
+ +
+
+ +
+ setEmail(e.target.value)} + className="w-full pl-12 pr-4 py-3.5 bg-gray-50/50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400 transition-all duration-200" + /> +
+
+ + {/* 비밀번호 입력 */} +
+ +
+
+ +
+ setPassword(e.target.value)} + className="w-full pl-12 pr-4 py-3.5 bg-gray-50/50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400 transition-all duration-200" + /> +
+
+ + {/* 에러 메시지 */} + {error && ( +
+

{error}

+
+ )} + + {/* 로그인 버튼 */} + + + + {/* 하단 안내 */} +
+

+ 로그인 문제 발생 시 관리자에게 문의하세요 +

+
+
+ + {/* 푸터 */} +
+ © 2024 Mailbox Service. All rights reserved. +
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/components/MailDetail.jsx b/frontend/src/components/MailDetail.jsx new file mode 100644 index 0000000..59e5c10 --- /dev/null +++ b/frontend/src/components/MailDetail.jsx @@ -0,0 +1,738 @@ +/** + * 메일 상세 보기 컴포넌트 + * 선택된 메일의 내용, 첨부파일, 액션 버튼 표시 + */ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMail } from '../context/MailContext'; +import { Trash2, Printer, Star, Reply, Forward, Mail as MailIcon, MailOpen, Archive, Paperclip, Download, FileText, Image, File, Edit, AlertOctagon, Languages, Sparkles } from 'lucide-react'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import Fade from '@mui/material/Fade'; +import toast from 'react-hot-toast'; +import { decodeHtmlEntities } from '../utils/decodeHtmlEntities'; +import { encodeEmailId } from '../utils/emailIdEncoder'; +import { HighlightText } from '../utils/highlightText'; +import ConfirmDialog from './ConfirmDialog'; + +const MailDetail = ({ onContinueDraft, onReply, onForward }) => { + const navigate = useNavigate(); + const { selectedEmail, setSelectedEmail, toggleStar, moveToTrash, markAsUnread, markAsRead, restoreEmail, deleteEmail, selectedBox, moveToSpam, moveEmail, userNames, isSearchMode, searchQuery } = useMail(); + + // 확인 다이얼로그 상태 + const [confirmDialog, setConfirmDialog] = useState({ isOpen: false, type: '', title: '', message: '', onConfirm: () => {} }); + + // 번역 상태 + const [isTranslating, setIsTranslating] = useState(false); + const [translatedContent, setTranslatedContent] = useState(null); + const [showTranslation, setShowTranslation] = useState(false); + const [targetLang, setTargetLang] = useState('ko'); + const [usedModel, setUsedModel] = useState(''); + + // 메일 변경 시 번역 상태 초기화 + useEffect(() => { + setTranslatedContent(null); + setShowTranslation(false); + setIsTranslating(false); + setUsedModel(''); + }, [selectedEmail?.id]); + + // 삭제 버튼 핸들러 (휴지통으로 이동) - 확인 다이얼로그 후 실행취소 가능 + const handleDelete = () => { + setConfirmDialog({ + isOpen: true, + type: 'danger', + title: '메일 삭제', + message: '이 메일을 휴지통으로 이동하시겠습니까?', + confirmText: '삭제', + onConfirm: async () => { + if (selectedEmail) { + const result = await moveToTrash(selectedEmail.id, selectedBox); + + if (result?.trashId) { + toast((t) => ( +
+ 휴지통으로 이동했습니다. + +
+ ), { duration: 5000 }); + } else { + toast.success('휴지통으로 이동했습니다.'); + } + } + } + }); + }; + + // 영구 삭제 핸들러 (휴지통에서) - 확인 다이얼로그 + const handlePermanentDelete = () => { + setConfirmDialog({ + isOpen: true, + type: 'danger', + title: '영구 삭제', + message: '이 메일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + confirmText: '영구 삭제', + onConfirm: () => { + if (selectedEmail) { + deleteEmail(selectedEmail.id, selectedBox); + toast.success('메일이 영구 삭제되었습니다.'); + } + } + }); + }; + + // 읽음/읽지않음 토글 핸들러 + const handleToggleRead = () => { + if (selectedEmail) { + if (selectedEmail.isRead) { + markAsUnread(selectedEmail.id, selectedBox); + toast.success('읽지 않음으로 표시했습니다.'); + } else { + markAsRead(selectedEmail.id, selectedBox); + toast.success('읽음으로 표시했습니다.'); + } + } + }; + + // 복구 핸들러 (휴지통에서) - 상세 화면 닫기 + const handleRestore = async () => { + if (selectedEmail) { + await restoreEmail(selectedEmail.id, selectedBox); + setSelectedEmail(null); // 상세 화면 닫기 + toast.success('메일이 복구되었습니다.'); + } + }; + + // 별표(중요) 토글 핸들러 - 확인 다이얼로그 후 이동 + const handleToggleStar = (e) => { + e.stopPropagation(); + if (!selectedEmail) return; + + const isCurrentlyStarred = selectedBox === 'IMPORTANT'; + + setConfirmDialog({ + isOpen: true, + type: 'star', + title: isCurrentlyStarred ? '중요 표시 해제' : '중요 표시', + message: isCurrentlyStarred + ? '중요 표시를 해제하고 받은편지함으로 이동하시겠습니까?' + : '이 메일을 중요편지함으로 이동하시겠습니까?', + confirmText: isCurrentlyStarred ? '해제' : '이동', + onConfirm: async () => { + const result = await toggleStar(selectedEmail.id); + if (result) { + if (result.movedTo === 'important') { + toast.success('중요편지함으로 이동했습니다.'); + } else { + toast.success('중요 표시를 해제했습니다.'); + } + navigate(`/mail/${result.movedTo}/${encodeEmailId(result.newEmailId)}`); + } + } + }); + }; + + // 인쇄 핸들러 - 새 창에서 이메일 내용만 인쇄 + const handlePrint = () => { + if (!selectedEmail) return; + + const printWindow = window.open('', '_blank'); + if (!printWindow) { + toast.error('팝업이 차단되었습니다. 팝업을 허용해주세요.'); + return; + } + + const emailContent = ` + + + + + ${selectedEmail.subject || '(제목 없음)'} + + + +

${selectedEmail.subject || '(제목 없음)'}

+
+

보낸 사람: ${selectedEmail.from}

+

받는 사람: ${selectedEmail.to}

+

날짜: ${new Date(selectedEmail.date).toLocaleString('ko-KR')}

+
+
+ ${selectedEmail.html || selectedEmail.text?.replace(/\n/g, '
') || '내용 없음'} +
+ + + `; + + printWindow.document.write(emailContent); + printWindow.document.close(); + printWindow.focus(); + + // 잠시 후 인쇄 다이얼로그 열기 + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); + }; + // 번역 핸들러 + const handleTranslate = async (lang = 'ko') => { + if (isTranslating) return; + + const content = selectedEmail.html || selectedEmail.text; + if (!content) { + toast.error('번역할 내용이 없습니다.'); + return; + } + + setIsTranslating(true); + setTargetLang(lang); + + // 현재 설정된 모델명 가져오기 + try { + const token = localStorage.getItem('email_token'); + const modelRes = await fetch('/api/emails/gemini-model', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (modelRes.ok) { + const modelData = await modelRes.json(); + setUsedModel(modelData.modelName || 'Gemini AI'); + } else { + setUsedModel('Gemini AI'); + } + } catch (e) { + setUsedModel('Gemini AI'); + } + + try { + const token = localStorage.getItem('email_token'); + const res = await fetch('/api/emails/translate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + emailId: selectedEmail.id, + mailbox: selectedBox || 'inbox', + text: content, + targetLang: lang + }) + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || '번역 실패'); + } + + const data = await res.json(); + setTranslatedContent(data.translatedText); + setShowTranslation(true); + setUsedModel(data.modelName || 'Gemini AI'); + + if (data.cached) { + toast.success('저장된 번역을 불러왔습니다.'); + } else { + toast.success('번역이 완료되었습니다.'); + } + } catch (error) { + toast.error(error.message || '번역에 실패했습니다.'); + setTranslatedContent(null); + setShowTranslation(false); + } finally { + setIsTranslating(false); + } + }; + + // 번역 토글 (원본/번역 전환) + const toggleTranslation = () => { + if (translatedContent) { + setShowTranslation(!showTranslation); + } + }; + + if (!selectedEmail) { + return ( +
+ {/* 상단 툴바 (빈 상태) */} +
+
+
+ + {/* 빈 상태 메시지 */} +
+ +

메일을 선택하세요

+

목록에서 메일을 클릭하면 여기에 표시됩니다

+
+
+ ); + } + + const formatDate = (dateString) => { + const date = new Date(dateString); + return date.toLocaleString('ko-KR', { + year: 'numeric', month: 'long', day: 'numeric', + hour: 'numeric', minute: 'numeric', hour12: true + }); + }; + + const getSenderInfo = (fromStr) => { + if (!fromStr) return { name: '알 수 없음', email: '' }; + + // "이름" 또는 이름 형식 + const match = fromStr.match(/^(.*?)\s*<(.*?)>$/); + let headerName = ''; + let parsedEmail = ''; + + if (match) { + headerName = match[1].replace(/['"]/g, '').trim(); + parsedEmail = match[2]; + } else if (fromStr.includes('@')) { + // 이메일만 있는 경우 (email@domain.com) + parsedEmail = fromStr; + } else { + return { name: decodeHtmlEntities(fromStr), email: '' }; + } + + // 1순위: DB에 저장된 사용자면 해당 이름 사용 + if (userNames && userNames[parsedEmail]) { + return { name: userNames[parsedEmail], email: parsedEmail }; + } + + // 2순위: From 헤더에 이름이 있으면 해당 이름 사용 + if (headerName) { + return { name: decodeHtmlEntities(headerName), email: parsedEmail }; + } + + // 3순위: 이름이 없으면 이메일 그대로 표시 + return { name: parsedEmail, email: parsedEmail }; + }; + + const { name, email } = getSenderInfo(selectedEmail.from); + + // 파일 타입에 따른 아이콘 + const getFileIcon = (filename) => { + const ext = filename.split('.').pop()?.toLowerCase(); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) { + return ; + } + if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) { + return ; + } + return ; + }; + + // 텍스트에서 URL을 클릭 가능한 링크로 변환 + const linkifyText = (text) => { + const urlPattern = /(https?:\/\/[^\s]+)/g; + const parts = text.split(urlPattern); + + return parts.map((part, index) => { + // URL 패턴 검사 (새 regex 인스턴스 사용) + if (/^https?:\/\//.test(part)) { + return ( + e.stopPropagation()} + > + {part} + + ); + } + return part; + }); + }; + + const handleDownload = async (filename) => { + try { + const token = localStorage.getItem('email_token'); + const mailbox = selectedEmail.mailbox || 'INBOX'; + const url = `/api/emails/${selectedEmail.id}/attachments/${encodeURIComponent(filename)}?mailbox=${mailbox}`; + + const response = await fetch(url, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('다운로드 실패'); + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + } catch (error) { + console.error('첨부파일 다운로드 오류:', error); + toast.error('파일 다운로드에 실패했습니다.'); + } + }; + + // 중요편지함에 있으면 무조건 별표 상태 (메일함 자체가 별표의 의미이므로) + const isStarred = selectedBox === 'IMPORTANT'; + + return ( + <> +
+ {/* 상단 툴바 */} +
+
+
+ {/* 휴지통 전용 툴바 */} + {selectedBox === 'TRASH' ? ( + <> + + + + + + + + + + + ) : ( + <> + {/* 일반 툴바 */} + + + + {/* 임시보관함이 아닐 때만 추가 버튼 표시 */} + {selectedEmail.mailbox !== 'DRAFTS' && ( + <> + + + + {/* 스팸함 이동 버튼 (스팸함/중요편지함 제외) */} + {selectedBox !== 'SPAM' && selectedBox !== 'IMPORTANT' && ( + + + + )} + {/* 스팸함에서 받은편지함으로 이동 */} + {selectedBox === 'SPAM' && ( + + + + )} + + + + + + + + + + + )} + + )} +
+
+ + {/* 스크롤 가능한 콘텐츠 (인쇄 영역) */} +