From 4c42cc7b53a0b55acb9962c5a0333a9052e1ffd9 Mon Sep 17 00:00:00 2001 From: syc0123 Date: Mon, 30 Mar 2026 17:17:20 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20IMAP=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - IMAP 계정 등록/수정/삭제/연결테스트 - SSE 스트리밍으로 메일 목록 로드 (폴더별 지원) - 메일 상세 조회, 읽음 처리, 삭제(휴지통 이동), 폴더 이동 - 첨부파일 다운로드 (ReadableStream 진행바) - SMTP 발송, 답장, 전달 - imapConnectionPool, mailCache 서비스 - encryptionService Node 22+ 호환 수정 - authMiddleware query token 지원 추가 Co-Authored-By: Claude Sonnet 4.6 --- backend-node/package-lock.json | 213 ++++ backend-node/package.json | 2 + backend-node/src/app.ts | 8 + .../src/controllers/userMailController.ts | 358 +++++++ backend-node/src/database/runMigration.ts | 29 + backend-node/src/middleware/authMiddleware.ts | 4 +- backend-node/src/routes/userMailRoutes.ts | 26 + .../src/services/encryptionService.ts | 4 +- .../src/services/imapConnectionPool.ts | 109 ++ backend-node/src/services/mailCache.ts | 43 + .../src/services/userMailAccountService.ts | 121 +++ .../src/services/userMailImapService.ts | 398 ++++++++ .../src/services/userMailSmtpService.ts | 63 ++ .../app/(main)/mail/imap/ComposeDialog.tsx | 120 +++ frontend/app/(main)/mail/imap/page.tsx | 963 ++++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 1 + frontend/lib/api/userMail.ts | 398 ++++++++ frontend/package-lock.json | 263 +++-- frontend/package.json | 8 +- 19 files changed, 3011 insertions(+), 120 deletions(-) create mode 100644 backend-node/src/controllers/userMailController.ts create mode 100644 backend-node/src/routes/userMailRoutes.ts create mode 100644 backend-node/src/services/imapConnectionPool.ts create mode 100644 backend-node/src/services/mailCache.ts create mode 100644 backend-node/src/services/userMailAccountService.ts create mode 100644 backend-node/src/services/userMailImapService.ts create mode 100644 backend-node/src/services/userMailSmtpService.ts create mode 100644 frontend/app/(main)/mail/imap/ComposeDialog.tsx create mode 100644 frontend/app/(main)/mail/imap/page.tsx create mode 100644 frontend/lib/api/userMail.ts diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 24ef7619..117d8632 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -26,6 +26,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -34,6 +35,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -2361,6 +2363,12 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -3900,6 +3908,17 @@ "dev": true, "license": "ISC" }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4120,6 +4139,15 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -7215,6 +7243,48 @@ "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "license": "MIT" }, + "node_modules/imapflow": { + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz", + "integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1", + "nodemailer": "8.0.4", + "pino": "10.3.1", + "socks": "2.8.7" + } + }, + "node_modules/imapflow/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/imapflow/node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -7291,6 +7361,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9039,6 +9118,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-pop3": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/node-pop3/-/node-pop3-0.11.0.tgz", + "integrity": "sha512-M5qRCamSTxu5lIVBW9q6XyC6nH30fZxTdTQDzfHRSaLl8CCiZMSh80rDnIysB6ECvh9j8sf8+KveEQpLDRmMYg==", + "license": "MIT", + "bin": { + "pop": "bin/pop.js" + }, + "engines": { + "node": "^20.11.0 || >= 22.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.21", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", @@ -9212,6 +9303,15 @@ "node": ">= 0.4" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9641,6 +9741,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -9872,6 +10009,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -9993,6 +10146,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quill": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", @@ -10187,6 +10346,15 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redis": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", @@ -10725,6 +10893,39 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -11098,6 +11299,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tlds": { "version": "1.260.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 2217eff6..b22309b1 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -40,6 +40,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -48,6 +49,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 686dc471..69e6634f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -44,6 +44,8 @@ process.on("SIGTERM", () => { logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작..."); const { stopAiAssistant } = require("./utils/startAiAssistant"); stopAiAssistant(); + const { imapConnectionPool } = require("./services/imapConnectionPool"); + imapConnectionPool.destroyAll(); process.exit(0); }); @@ -52,6 +54,8 @@ process.on("SIGINT", () => { logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작..."); const { stopAiAssistant } = require("./utils/startAiAssistant"); stopAiAssistant(); + const { imapConnectionPool } = require("./services/imapConnectionPool"); + imapConnectionPool.destroyAll(); process.exit(0); }); @@ -131,6 +135,7 @@ import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템 +import userMailRoutes from "./routes/userMailRoutes"; // 사용자 메일 계정 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 @@ -377,6 +382,7 @@ app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/approval", approvalRoutes); // 결재 시스템 +app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -419,12 +425,14 @@ async function initializeServices() { runTableHistoryActionMigration, runDtgManagementLogMigration, runApprovalSystemMigration, + runUserMailAccountsMigration, } = await import("./database/runMigration"); await runDashboardMigration(); await runTableHistoryActionMigration(); await runDtgManagementLogMigration(); await runApprovalSystemMigration(); + await runUserMailAccountsMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/controllers/userMailController.ts b/backend-node/src/controllers/userMailController.ts new file mode 100644 index 00000000..65eb3c22 --- /dev/null +++ b/backend-node/src/controllers/userMailController.ts @@ -0,0 +1,358 @@ +import { Response } from 'express'; +import { AuthenticatedRequest } from '../types/auth'; +import { userMailAccountService } from '../services/userMailAccountService'; +import { userMailImapService } from '../services/userMailImapService'; +import { userMailSmtpService } from '../services/userMailSmtpService'; +import { encryptionService } from '../services/encryptionService'; +import { imapConnectionPool } from '../services/imapConnectionPool'; +import { mailCache } from '../services/mailCache'; + +class UserMailController { + async listAccounts(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const protocol = req.query.protocol as string | undefined; + const accounts = await userMailAccountService.getAccountsByUserId(userId, protocol); + // 비밀번호 제거 후 반환 + const safe = accounts.map(({ password, ...rest }) => rest); + res.json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async createAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + // 저장 전 연결 테스트 (임시 account 객체 사용, 평문 비밀번호를 암호화해서 전달) + const tempAccount = { ...req.body, id: 0, userId, status: 'active', password: encryptionService.encrypt(req.body.password) }; + const service = userMailImapService; + const testResult = await service.testConnection(tempAccount); + if (!testResult.success) { + return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` }); + } + + const account = await userMailAccountService.createAccount(userId, req.body); + const { password, ...safe } = account; + res.status(201).json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async updateAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + + // 비밀번호 변경이 포함된 경우 연결 테스트 + if (req.body.password) { + const existing = await userMailAccountService.getAccountById(accountId, userId); + if (!existing) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const tempAccount = { ...existing, ...req.body, password: encryptionService.encrypt(req.body.password) }; + const service = userMailImapService; + const testResult = await service.testConnection(tempAccount); + if (!testResult.success) { + return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` }); + } + } + + const account = await userMailAccountService.updateAccount(accountId, userId, req.body); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + imapConnectionPool.destroyByAccount(accountId); + mailCache.invalidateByPrefix(`mailList:${accountId}:`); + mailCache.invalidateByPrefix(`mailDetail:${accountId}:`); + + const { password, ...safe } = account; + res.json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async deleteAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const deleted = await userMailAccountService.deleteAccount(accountId, userId); + if (!deleted) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + imapConnectionPool.destroyByAccount(accountId); + mailCache.invalidateByPrefix(`mailList:${accountId}:`); + mailCache.invalidateByPrefix(`mailDetail:${accountId}:`); + + res.json({ success: true, message: '계정이 삭제되었습니다.' }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async testConnectionDirect(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const { protocol, host, port, useTls, username, password } = req.body; + if (!protocol || !host || !port || !username || !password) { + return res.status(400).json({ success: false, message: '필수 항목 누락' }); + } + + const tempAccount = { + id: 0, userId, displayName: '', email: '', protocol, host, port, + useTls: useTls ?? true, username, status: 'active', + password: encryptionService.encrypt(password), + createdAt: new Date(), updatedAt: new Date(), + }; + + const service = userMailImapService; + const result = await service.testConnection(tempAccount); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async testConnection(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const service = userMailImapService; + const result = await service.testConnection(account); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async listMails(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const limit = parseInt(req.query.limit as string) || 50; + const service = userMailImapService; + const mails = await service.fetchMailList(account, limit); + res.json({ success: true, data: mails }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async streamMails(req: AuthenticatedRequest, res: Response) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const userId = (req as any).user?.userId; + const accountId = parseInt(req.params.accountId); + const limit = parseInt(req.query.limit as string) || 20; + const before = req.query.before ? parseInt(req.query.before as string) : null; + + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) { + res.write(`event: error\ndata: ${JSON.stringify({ message: '계정을 찾을 수 없습니다.' })}\n\n`); + return res.end(); + } + + let ended = false; + req.on('close', () => { ended = true; }); + + await userMailImapService.fetchMailListStream( + account, limit, before, + (mail) => { + if (!ended) res.write(`data: ${JSON.stringify(mail)}\n\n`); + }, + () => { + if (!ended) { + res.write(`event: done\ndata: {}\n\n`); + res.end(); + } + }, + (err) => { + if (!ended) { + res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); + res.end(); + } + } + ); + } + + async getMailDetail(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const detail = await service.getMailDetail(account, seqno); + if (!detail) return res.status(404).json({ success: false, message: '메일을 찾을 수 없습니다.' }); + + res.json({ success: true, data: detail }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async markAsRead(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const result = await service.markAsRead(account, seqno); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async deleteMail(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const result = await service.deleteMail(account, seqno); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async listFolders(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folders = await userMailImapService.listFolders(account); + res.json({ success: true, data: folders }); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async streamFolderMails(req: AuthenticatedRequest, res: Response): Promise { + const accountId = parseInt(req.params.accountId); + const folder = decodeURIComponent(req.params.folder); + const limit = parseInt(req.query.limit as string) || 20; + const before = req.query.before ? parseInt(req.query.before as string) : null; + + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + await userMailImapService.streamMailsByFolder( + account, folder, limit, before, + (mail) => { + res.write(`event: message\ndata: ${JSON.stringify(mail)}\n\n`); + }, + () => { + res.write(`event: done\ndata: {}\n\n`); + res.end(); + }, + (err) => { + res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); + res.end(); + } + ); + } + + async moveMail(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const { targetFolder } = req.body; + if (!targetFolder) { res.status(400).json({ success: false, message: 'targetFolder 필요' }); return; } + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const result = await userMailImapService.moveMail(account, seqno, targetFolder); + res.json(result); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async getAttachments(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folder = (req.query.folder as string) || 'INBOX'; + const attachments = await userMailImapService.getAttachmentList(account, seqno, folder); + res.json({ success: true, data: attachments }); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async downloadAttachment(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const partId = decodeURIComponent(req.params.partId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folder = (req.query.folder as string) || 'INBOX'; + const filenameHint = (req.params.filename as string | undefined) || (req.query.filename as string | undefined); + await userMailImapService.downloadAttachment(account, seqno, partId, res, folder, filenameHint); + } catch (err) { + if (!res.headersSent) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + } + + async sendMail(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const result = await userMailSmtpService.sendMail(account, req.body); + res.json(result); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } +} + +export const userMailController = new UserMailController(); diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 07f714d6..f44d2851 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -112,6 +112,35 @@ export async function runTableHistoryActionMigration() { /** * DTG Management 테이블 이력 시스템 마이그레이션 */ +export async function runUserMailAccountsMigration() { + try { + console.log("🔄 사용자 메일 계정 테이블 마이그레이션 시작..."); + await PostgreSQLService.query(` + CREATE TABLE IF NOT EXISTS user_mail_accounts ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(100) NOT NULL, + display_name VARCHAR(200) NOT NULL, + email VARCHAR(255) NOT NULL, + protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')), + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL, + use_tls BOOLEAN NOT NULL DEFAULT true, + username VARCHAR(255) NOT NULL, + password TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + await PostgreSQLService.query(` + CREATE INDEX IF NOT EXISTS idx_user_mail_accounts_user_id ON user_mail_accounts(user_id) + `); + console.log("✅ 사용자 메일 계정 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 사용자 메일 계정 테이블 마이그레이션 실패:", error); + } +} + export async function runDtgManagementLogMigration() { try { console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 8dfe28b3..b967f32e 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -29,9 +29,9 @@ export const authenticateToken = async ( next: NextFunction ): Promise => { try { - // Authorization 헤더에서 토큰 추출 + // Authorization 헤더 또는 query param에서 토큰 추출 (파일 다운로드용) const authHeader = req.get("Authorization"); - const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN + const token = (authHeader && authHeader.split(" ")[1]) || (req.query.token as string) || null; if (!token) { res.status(401).json({ diff --git a/backend-node/src/routes/userMailRoutes.ts b/backend-node/src/routes/userMailRoutes.ts new file mode 100644 index 00000000..d1c1a3db --- /dev/null +++ b/backend-node/src/routes/userMailRoutes.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { authenticateToken } from '../middleware/authMiddleware'; +import { userMailController } from '../controllers/userMailController'; + +const router = express.Router(); +router.use(authenticateToken); + +router.post('/test-connection', (req, res) => userMailController.testConnectionDirect(req as any, res)); +router.get('/accounts', (req, res) => userMailController.listAccounts(req as any, res)); +router.post('/accounts', (req, res) => userMailController.createAccount(req as any, res)); +router.put('/accounts/:accountId', (req, res) => userMailController.updateAccount(req as any, res)); +router.delete('/accounts/:accountId', (req, res) => userMailController.deleteAccount(req as any, res)); +router.post('/accounts/:accountId/test', (req, res) => userMailController.testConnection(req as any, res)); +router.get('/accounts/:accountId/mails/stream', (req, res) => userMailController.streamMails(req as any, res)); +router.get('/accounts/:accountId/mails', (req, res) => userMailController.listMails(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.getMailDetail(req as any, res)); +router.post('/accounts/:accountId/mails/:seqno/mark-read', (req, res) => userMailController.markAsRead(req as any, res)); +router.delete('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.deleteMail(req as any, res)); +router.get('/accounts/:accountId/folders', (req, res) => userMailController.listFolders(req as any, res)); +router.get('/accounts/:accountId/folders/:folder/mails/stream', (req, res) => userMailController.streamFolderMails(req as any, res)); +router.post('/accounts/:accountId/mails/:seqno/move', (req, res) => userMailController.moveMail(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno/attachments', (req, res) => userMailController.getAttachments(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno/attachment/:partId', (req, res) => userMailController.downloadAttachment(req as any, res)); +router.post('/accounts/:accountId/send', (req, res) => userMailController.sendMail(req as any, res)); + +export default router; diff --git a/backend-node/src/services/encryptionService.ts b/backend-node/src/services/encryptionService.ts index a3608b2e..c21f009c 100644 --- a/backend-node/src/services/encryptionService.ts +++ b/backend-node/src/services/encryptionService.ts @@ -14,7 +14,7 @@ class EncryptionService { encrypt(text: string): string { const iv = crypto.randomBytes(16); - const cipher = crypto.createCipher(this.algorithm, this.key); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); @@ -34,7 +34,7 @@ class EncryptionService { const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); - const decipher = crypto.createDecipher(this.algorithm, this.key); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); diff --git a/backend-node/src/services/imapConnectionPool.ts b/backend-node/src/services/imapConnectionPool.ts new file mode 100644 index 00000000..04233b5e --- /dev/null +++ b/backend-node/src/services/imapConnectionPool.ts @@ -0,0 +1,109 @@ +import { ImapFlow } from 'imapflow'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; + +interface PoolEntry { + client: ImapFlow; + accountId: number; + lastUsed: number; + busy: boolean; + queue: Array<{ fn: (client: ImapFlow) => Promise; resolve: (v: any) => void; reject: (e: any) => void }>; +} + +class ImapConnectionPool { + private pool = new Map(); + private readonly maxIdleMs = 300_000; + + constructor() { + setInterval(() => this.cleanupIdle(), 60_000); + process.on('SIGTERM', () => this.destroyAll()); + process.on('SIGINT', () => this.destroyAll()); + } + + async execute(account: UserMailAccount, fn: (client: ImapFlow) => Promise): Promise { + const decryptedPassword = encryptionService.decrypt(account.password); + let entry = this.pool.get(account.id); + + if (entry && !entry.client.usable) { + this.pool.delete(account.id); + entry = undefined; + } + + if (!entry) { + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: account.useTls, + auth: { user: account.username, pass: decryptedPassword }, + logger: false as any, + tls: { rejectUnauthorized: false }, + }); + await client.connect(); + entry = { client, accountId: account.id, lastUsed: Date.now(), busy: false, queue: [] }; + this.pool.set(account.id, entry); + + client.on('close', () => { + const e = this.pool.get(account.id); + if (e && e.client === client) { + this.pool.delete(account.id); + for (const pending of e.queue) pending.reject(new Error('IMAP 연결이 끊겼습니다')); + e.queue = []; + } + }); + } + + if (entry.busy) { + return new Promise((resolve, reject) => { + entry!.queue.push({ fn: fn as any, resolve, reject }); + }); + } + + return this.runWithEntry(entry, fn); + } + + private async runWithEntry(entry: PoolEntry, fn: (client: ImapFlow) => Promise): Promise { + entry.busy = true; + entry.lastUsed = Date.now(); + try { + return await fn(entry.client); + } catch (err) { + if (!entry.client.usable) { + this.pool.delete(entry.accountId); + } + throw err; + } finally { + entry.busy = false; + if (entry.queue.length > 0) { + const next = entry.queue.shift()!; + this.runWithEntry(entry, next.fn).then(next.resolve).catch(next.reject); + } + } + } + + private cleanupIdle() { + const now = Date.now(); + for (const [id, entry] of this.pool.entries()) { + if (!entry.busy && entry.queue.length === 0 && now - entry.lastUsed > this.maxIdleMs) { + try { entry.client.logout(); } catch {} + this.pool.delete(id); + } + } + } + + destroyByAccount(accountId: number) { + const entry = this.pool.get(accountId); + if (entry) { + try { entry.client.logout(); } catch {} + this.pool.delete(accountId); + } + } + + destroyAll() { + for (const entry of this.pool.values()) { + try { entry.client.logout(); } catch {} + } + this.pool.clear(); + } +} + +export const imapConnectionPool = new ImapConnectionPool(); diff --git a/backend-node/src/services/mailCache.ts b/backend-node/src/services/mailCache.ts new file mode 100644 index 00000000..b410280a --- /dev/null +++ b/backend-node/src/services/mailCache.ts @@ -0,0 +1,43 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +class MailCache { + private cache = new Map>(); + private readonly maxEntries = 1000; + + constructor() { + setInterval(() => this.sweep(), 60_000); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + return entry.data as T; + } + + set(key: string, data: T, ttlMs: number) { + if (this.cache.size >= this.maxEntries) this.sweep(); + this.cache.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + invalidateByPrefix(prefix: string) { + for (const key of this.cache.keys()) { + if (key.startsWith(prefix)) this.cache.delete(key); + } + } + + private sweep() { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) this.cache.delete(key); + } + } +} + +export const mailCache = new MailCache(); diff --git a/backend-node/src/services/userMailAccountService.ts b/backend-node/src/services/userMailAccountService.ts new file mode 100644 index 00000000..129fd0f2 --- /dev/null +++ b/backend-node/src/services/userMailAccountService.ts @@ -0,0 +1,121 @@ +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { encryptionService } from "./encryptionService"; + +export interface UserMailAccount { + id: number; + userId: string; + displayName: string; + email: string; + protocol: 'imap'; + host: string; + port: number; + useTls: boolean; + username: string; + password: string; // 암호화된 상태 + status: string; + createdAt: string | Date; + updatedAt: string | Date; +} + +export interface CreateUserMailAccountDto { + displayName: string; + email: string; + protocol: 'imap'; + host: string; + port: number; + useTls: boolean; + username: string; + password: string; // 평문 (서비스에서 암호화) +} + +function rowToAccount(row: any): UserMailAccount { + return { + id: row.id, + userId: row.user_id, + displayName: row.display_name, + email: row.email, + protocol: row.protocol, + host: row.host, + port: row.port, + useTls: row.use_tls, + username: row.username, + password: row.password, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +class UserMailAccountService { + async getAccountsByUserId(userId: string, protocol?: string): Promise { + let query = 'SELECT * FROM user_mail_accounts WHERE user_id = $1'; + const params: any[] = [userId]; + + if (protocol) { + query += ' AND protocol = $2'; + params.push(protocol); + } + + query += ' ORDER BY created_at DESC'; + const result = await PostgreSQLService.query(query, params); + return result.rows.map(rowToAccount); + } + + async getAccountById(id: number, userId: string): Promise { + const result = await PostgreSQLService.query( + 'SELECT * FROM user_mail_accounts WHERE id = $1 AND user_id = $2', + [id, userId] + ); + return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null; + } + + async createAccount(userId: string, dto: CreateUserMailAccountDto): Promise { + const encryptedPassword = encryptionService.encrypt(dto.password); + const result = await PostgreSQLService.query( + `INSERT INTO user_mail_accounts (user_id, display_name, email, protocol, host, port, use_tls, username, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [userId, dto.displayName, dto.email, dto.protocol, dto.host, dto.port, dto.useTls, dto.username, encryptedPassword] + ); + return rowToAccount(result.rows[0]); + } + + async updateAccount(id: number, userId: string, dto: Partial): Promise { + const existing = await this.getAccountById(id, userId); + if (!existing) return null; + + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.displayName !== undefined) { fields.push(`display_name = $${paramIndex++}`); values.push(dto.displayName); } + if (dto.email !== undefined) { fields.push(`email = $${paramIndex++}`); values.push(dto.email); } + if (dto.protocol !== undefined) { fields.push(`protocol = $${paramIndex++}`); values.push(dto.protocol); } + if (dto.host !== undefined) { fields.push(`host = $${paramIndex++}`); values.push(dto.host); } + if (dto.port !== undefined) { fields.push(`port = $${paramIndex++}`); values.push(dto.port); } + if (dto.useTls !== undefined) { fields.push(`use_tls = $${paramIndex++}`); values.push(dto.useTls); } + if (dto.username !== undefined) { fields.push(`username = $${paramIndex++}`); values.push(dto.username); } + if (dto.password !== undefined) { fields.push(`password = $${paramIndex++}`); values.push(encryptionService.encrypt(dto.password)); } + + if (fields.length === 0) return existing; + + fields.push(`updated_at = NOW()`); + values.push(id, userId); + + const result = await PostgreSQLService.query( + `UPDATE user_mail_accounts SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`, + values + ); + return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null; + } + + async deleteAccount(id: number, userId: string): Promise { + const result = await PostgreSQLService.query( + 'DELETE FROM user_mail_accounts WHERE id = $1 AND user_id = $2', + [id, userId] + ); + return result.rowCount > 0; + } +} + +export const userMailAccountService = new UserMailAccountService(); diff --git a/backend-node/src/services/userMailImapService.ts b/backend-node/src/services/userMailImapService.ts new file mode 100644 index 00000000..a9d483e0 --- /dev/null +++ b/backend-node/src/services/userMailImapService.ts @@ -0,0 +1,398 @@ +import { ImapFlow } from 'imapflow'; +import { simpleParser } from 'mailparser'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; +import { imapConnectionPool } from './imapConnectionPool'; +import { mailCache } from './mailCache'; + +export interface ReceivedMail { + id: string; + messageId: string; + from: string; + to: string; + subject: string; + date: Date; + preview: string; + isRead: boolean; + hasAttachments: boolean; +} + +export interface MailDetail extends ReceivedMail { + htmlBody: string; + textBody: string; + cc?: string; + bcc?: string; + attachments: Array<{ + filename: string; + contentType: string; + size: number; + }>; +} + +class UserMailImapService { + async fetchMailList(account: UserMailAccount, limit: number = 50): Promise { + const cacheKey = `mailList:${account.id}:INBOX:${limit}`; + const cached = mailCache.get(cacheKey); + if (cached) return cached; + + const mails = await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const status = await client.status('INBOX', { messages: true }); + const total = status.messages || 0; + if (total === 0) return []; + + const start = Math.max(1, total - limit + 1); + const range = `${start}:${total}`; + const result: ReceivedMail[] = []; + + for await (const msg of client.fetch(range, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + + result.push({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + + result.sort((a, b) => b.date.getTime() - a.date.getTime()); + return result; + } finally { + mailbox.release(); + } + }); + + mailCache.set(cacheKey, mails, 60_000); + return mails; + } + + async fetchMailListStream( + account: UserMailAccount, + limit: number = 20, + beforeSeqno: number | null = null, + onMail: (mail: ReceivedMail) => void, + onDone: () => void, + onError: (err: Error) => void + ): Promise { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const status = await client.status('INBOX', { messages: true }); + const total = status.messages || 0; + if (total === 0) { onDone(); return; } + + let start: number, end: number; + if (beforeSeqno !== null) { + end = beforeSeqno - 1; + start = Math.max(1, beforeSeqno - limit); + } else { + start = Math.max(1, total - limit + 1); + end = total; + } + + if (end < 1 || start > end) { onDone(); return; } + + for await (const msg of client.fetch(`${start}:${end}`, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + + onMail({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + onDone(); + } finally { + mailbox.release(); + } + }); + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))); + } + } + + async getMailDetail(account: UserMailAccount, seqno: number): Promise { + const cacheKey = `mailDetail:${account.id}:${seqno}`; + const cached = mailCache.get(cacheKey); + if (cached) return cached; + + const detail = await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const msg = await client.fetchOne(`${seqno}`, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + source: true, + }); + if (!msg) return null; + + const parsed = await simpleParser(msg.source as Buffer); + + const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; + const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; + const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc; + + return { + id: `${account.id}-imap-${seqno}`, + messageId: parsed.messageId || `${seqno}`, + from: fromAddress?.text || 'Unknown', + to: toAddress?.text || '', + cc: ccAddress?.text, + subject: parsed.subject || '(제목 없음)', + date: parsed.date || new Date(), + htmlBody: parsed.html || '', + textBody: parsed.text || '', + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments: (parsed.attachments?.length || 0) > 0, + attachments: (parsed.attachments || []).map((att: any) => ({ + filename: att.filename || 'unnamed', + contentType: att.contentType || 'application/octet-stream', + size: att.size || 0, + })), + } as MailDetail; + } finally { + mailbox.release(); + } + }); + + if (detail) mailCache.set(cacheKey, detail, 300_000); + return detail; + } + + async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + await client.messageFlagsAdd(`${seqno}`, ['\\Seen']); + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + return { success: true, message: '읽음 처리 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + // \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등) + const folders = await client.list(); + const trashFolder = folders.find(f => f.specialUse === '\\Trash'); + + const mailbox = await client.getMailboxLock('INBOX'); + try { + if (trashFolder) { + await client.messageMove(`${seqno}`, trashFolder.path); + } else { + await client.messageDelete(`${seqno}`); + } + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`); + return { success: true, message: '휴지통으로 이동 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async listFolders(account: UserMailAccount): Promise> { + return imapConnectionPool.execute(account, async (client) => { + const folders = await client.list({ statusQuery: { unseen: true } }); + return folders + .filter(f => f.listed) + .map(f => ({ + path: f.path, + name: f.name, + unseen: (f as any).status?.unseen ?? 0, + })); + }); + } + + async streamMailsByFolder( + account: UserMailAccount, + folder: string, + limit: number = 20, + beforeSeqno: number | null = null, + onMail: (mail: ReceivedMail) => void, + onDone: () => void, + onError: (err: Error) => void + ): Promise { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const status = await client.status(folder, { messages: true }); + const total = status.messages || 0; + if (total === 0) { onDone(); return; } + + let start: number, end: number; + if (beforeSeqno !== null) { + end = beforeSeqno - 1; + start = Math.max(1, beforeSeqno - limit); + } else { + start = Math.max(1, total - limit + 1); + end = total; + } + if (end < 1 || start > end) { onDone(); return; } + + for await (const msg of client.fetch(`${start}:${end}`, { + uid: true, flags: true, envelope: true, bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + onMail({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + onDone(); + } finally { + mailbox.release(); + } + }); + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))); + } + } + + async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + await client.messageMove(`${seqno}`, targetFolder); + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + return { success: true, message: '이동 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async downloadAttachment( + account: UserMailAccount, + seqno: number, + partId: string, + res: import('express').Response, + folder: string = 'INBOX', + filenameHint?: string + ): Promise { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const { meta, content } = await client.download(`${seqno}`, partId); + const rawFilename = filenameHint || (meta as any).filename || 'attachment'; + const encodedFilename = encodeURIComponent(rawFilename); + res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`); + res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream'); + if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size)); + await require('stream/promises').pipeline(content, res); + } finally { + mailbox.release(); + } + }); + } + + async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise> { + return imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true }); + if (!msg || !msg.bodyStructure) return []; + const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = []; + function walk(node: any, part: string) { + const filename = node.parameters?.name || node.dispositionParameters?.filename; + if (filename && node.type !== 'text' && node.type !== 'multipart') { + result.push({ + partId: node.part || part, + filename, + contentType: `${node.type}/${node.subtype}`, + size: node.size || 0, + }); + } + if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`)); + } + walk(msg.bodyStructure, '1'); + return result; + } finally { + mailbox.release(); + } + }); + } + + async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> { + const decryptedPassword = encryptionService.decrypt(account.password); + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: account.useTls, + auth: { user: account.username, pass: decryptedPassword }, + logger: false as any, + tls: { rejectUnauthorized: false }, + }); + try { + await client.connect(); + await client.logout(); + return { success: true, message: 'IMAP 연결 성공' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '연결 실패' }; + } + } +} + +export const userMailImapService = new UserMailImapService(); diff --git a/backend-node/src/services/userMailSmtpService.ts b/backend-node/src/services/userMailSmtpService.ts new file mode 100644 index 00000000..aab8a6a8 --- /dev/null +++ b/backend-node/src/services/userMailSmtpService.ts @@ -0,0 +1,63 @@ +import nodemailer from 'nodemailer'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; + +export interface SendMailDto { + to: string; + cc?: string; + subject: string; + html: string; + text?: string; + inReplyTo?: string; + references?: string; +} + +class UserMailSmtpService { + private getSmtpConfig(account: UserMailAccount) { + const decryptedPassword = encryptionService.decrypt(account.password); + // IMAP host에서 SMTP host 추론 + const smtpHost = account.host + .replace(/^imap\./, 'smtp.') + .replace(/^mail\./, 'smtp.'); + // 포트 추론: TLS → 465, plain → 587 + const port = account.useTls ? 465 : 587; + return { + host: smtpHost, + port, + secure: account.useTls, // 465: true, 587: false (STARTTLS) + auth: { user: account.username, pass: decryptedPassword }, + tls: { rejectUnauthorized: false }, + }; + } + + async sendMail(account: UserMailAccount, dto: SendMailDto): Promise<{ success: boolean; message: string }> { + try { + const transporter = nodemailer.createTransport(this.getSmtpConfig(account)); + await transporter.sendMail({ + from: `${account.displayName} <${account.email}>`, + to: dto.to, + cc: dto.cc, + subject: dto.subject, + html: dto.html, + text: dto.text, + inReplyTo: dto.inReplyTo, + references: dto.references, + }); + return { success: true, message: '발송 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '발송 실패' }; + } + } + + async testSmtpConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> { + try { + const transporter = nodemailer.createTransport(this.getSmtpConfig(account)); + await transporter.verify(); + return { success: true, message: 'SMTP 연결 성공' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : 'SMTP 연결 실패' }; + } + } +} + +export const userMailSmtpService = new UserMailSmtpService(); diff --git a/frontend/app/(main)/mail/imap/ComposeDialog.tsx b/frontend/app/(main)/mail/imap/ComposeDialog.tsx new file mode 100644 index 00000000..3de386f4 --- /dev/null +++ b/frontend/app/(main)/mail/imap/ComposeDialog.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import LinkExtension from "@tiptap/extension-link"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2, Send } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { sendUserMail } from "@/lib/api/userMail"; + +interface ComposeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + mode: "new" | "reply" | "forward"; + to: string; + setTo: (v: string) => void; + cc: string; + setCc: (v: string) => void; + subject: string; + setSubject: (v: string) => void; + initialHtml: string; + setInitialHtml: (v: string) => void; + inReplyTo: string; + references: string; + sending: boolean; + setSending: (v: boolean) => void; + accountId: number | null; +} + +export default function ComposeDialog({ + open, onOpenChange, mode, + to, setTo, cc, setCc, + subject, setSubject, + initialHtml, setInitialHtml, + inReplyTo, references, + sending, setSending, + accountId, +}: ComposeDialogProps) { + const editor = useEditor({ + extensions: [StarterKit, LinkExtension.configure({ openOnClick: false })], + content: initialHtml, + editorProps: { + attributes: { class: "min-h-[200px] p-2 border rounded focus:outline-none prose max-w-none" }, + }, + }); + + useEffect(() => { + if (editor) editor.commands.setContent(initialHtml); + }, [initialHtml, editor]); + + async function handleSend() { + if (!accountId || !editor) return; + setSending(true); + try { + const html = editor.getHTML(); + const result = await sendUserMail(accountId, { + to, + cc: cc || undefined, + subject, + html, + inReplyTo: inReplyTo || undefined, + references: references || undefined, + }); + if (result.success) { + onOpenChange(false); + setTo(""); setCc(""); setSubject(""); setInitialHtml(""); + } else { + alert(result.message); + } + } finally { + setSending(false); + } + } + + return ( + + + + + {mode === "reply" ? "답장" : mode === "forward" ? "전달" : "새 메일"} + + +
+
+ + setTo(e.target.value)} placeholder="to@example.com" /> +
+
+ + setCc(e.target.value)} placeholder="cc@example.com (선택)" /> +
+
+ + setSubject(e.target.value)} /> +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/app/(main)/mail/imap/page.tsx b/frontend/app/(main)/mail/imap/page.tsx new file mode 100644 index 00000000..c4ef8e9c --- /dev/null +++ b/frontend/app/(main)/mail/imap/page.tsx @@ -0,0 +1,963 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Mail, + Inbox, + RefreshCw, + Plus, + Settings, + Trash2, + Loader2, + Search, + ChevronRight, + Paperclip, + AlertCircle, + CheckCircle, + X, + Reply, + Forward, + FolderOpen, + Send, + Download, +} from "lucide-react"; +import DOMPurify from "isomorphic-dompurify"; +import { + getUserMailAccounts, + createUserMailAccount, + updateUserMailAccount, + deleteUserMailAccount, + testUserMailConnectionDirect, + streamUserMails, + getUserMailDetail, + markUserMailAsRead, + deleteUserMail, + getUserMailFolders, + moveUserMail, + sendUserMail, + getUserMailAttachments, + downloadAttachment, + streamFolderMails, + UserMailAccount, + ReceivedMail, + MailDetail, + CreateUserMailAccountDto, + MailFolder, + SendMailDto, + +} from "@/lib/api/userMail"; +const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false }); + +const DEFAULT_FORM: CreateUserMailAccountDto = { + displayName: "", + email: "", + protocol: "imap", + host: "", + port: 993, + useTls: true, + username: "", + password: "", +}; + +export default function ImapMailPage() { + const [accounts, setAccounts] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(null); + const [mailsMap, setMailsMap] = useState>(new Map()); + const [loadingMap, setLoadingMap] = useState>(new Map()); + const [minSeqnoMap, setMinSeqnoMap] = useState>(new Map()); + const [loadingMoreMap, setLoadingMoreMap] = useState>(new Map()); + const [selectedMail, setSelectedMail] = useState(null); + const [downloadProgress, setDownloadProgress] = useState>({}); + const [loadingAccounts, setLoadingAccounts] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + const [form, setForm] = useState(DEFAULT_FORM); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [testing, setTesting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // New states + const [folders, setFolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState("INBOX"); + const [composeOpen, setComposeOpen] = useState(false); + const [composeMode, setComposeMode] = useState<"new" | "reply" | "forward">("new"); + const [composeTo, setComposeTo] = useState(""); + const [composeCc, setComposeCc] = useState(""); + const [composeSubject, setComposeSubject] = useState(""); + const [composeInitialHtml, setComposeInitialHtml] = useState(""); + const [composeInReplyTo, setComposeInReplyTo] = useState(""); + const [composeReferences, setComposeReferences] = useState(""); + const [composeSending, setComposeSending] = useState(false); + + const detailCacheRef = useRef>(new Map()); + const prefetchingRef = useRef>(new Set()); + const mailsMapRef = useRef>(new Map()); + const hoverTimerRef = useRef(null); + + // 현재 선택 계정 기준 파생값 + const mails = selectedAccount ? (mailsMap.get(selectedAccount.id) || []) : []; + const loadingMails = selectedAccount ? (loadingMap.get(selectedAccount.id) ?? false) : false; + const minSeqno = selectedAccount ? (minSeqnoMap.get(selectedAccount.id) ?? null) : null; + const loadingMore = selectedAccount ? (loadingMoreMap.get(selectedAccount.id) ?? false) : false; + + const imapAccounts = accounts.filter((a) => a.protocol === "imap"); + + useEffect(() => { + loadAccounts(); + }, []); + + useEffect(() => { + if (selectedAccount) { + setSelectedMail(null); + setCurrentFolder("INBOX"); + loadFolders(selectedAccount); + // 아직 로딩 안 됐으면 시작 + if (!mailsMap.has(selectedAccount.id) && !loadingMap.get(selectedAccount.id)) { + startStream(selectedAccount); + } + } + }, [selectedAccount]); + + async function loadAccounts() { + setLoadingAccounts(true); + try { + const data = await getUserMailAccounts(); + setAccounts(data); + // 모든 계정 동시 프리로드 + const imapAccts = data.filter((a: UserMailAccount) => a.protocol === "imap"); + for (const account of imapAccts) { + startStream(account); + } + } catch (e) { + console.error("계정 목록 로드 실패:", e); + } finally { + setLoadingAccounts(false); + } + } + + async function loadFolders(account: UserMailAccount) { + try { + const data = await getUserMailFolders(account.id); + setFolders(data); + } catch { + setFolders([]); + } + } + + async function prefetchDetail(account: UserMailAccount, mail: ReceivedMail) { + if (detailCacheRef.current.has(mail.id)) return; + if (prefetchingRef.current.has(mail.id)) return; + prefetchingRef.current.add(mail.id); + try { + const seqno = parseInt(mail.id.split("-").pop() || "0"); + if (!seqno) return; + const detail = await getUserMailDetail(account.id, seqno); + if (detail) detailCacheRef.current.set(mail.id, detail); + } catch { + // 프리로드 실패 무시 + } finally { + prefetchingRef.current.delete(mail.id); + } + } + + function startStream(account: UserMailAccount, before: number | null = null, append = false) { + // 이미 로딩 중이면 스킵 (초기 로드 한정) + if (!append && loadingMap.get(account.id)) return; + + setLoadingMap((prev) => new Map(prev).set(account.id, true)); + + const cancel = streamUserMails( + account.id, 20, before, + (mail) => { + setMailsMap((prev) => { + const next = new Map(prev); + const existing = next.get(account.id) || []; + const updated = append + ? [...existing, mail] + : [mail, ...existing.filter((m) => m.id !== mail.id)]; + next.set(account.id, updated); + mailsMapRef.current = next; + return next; + }); + setMinSeqnoMap((prev) => { + const seqno = parseInt(mail.id.split("-").pop() || "0"); + const current = prev.get(account.id) ?? null; + return new Map(prev).set(account.id, current === null ? seqno : Math.min(current, seqno)); + }); + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + }, + () => { + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + setLoadingMoreMap((prev) => new Map(prev).set(account.id, false)); + // 상위 5개 자동 프리로드 (순차, Gmail은 throttling으로 느려지므로 간격 늘림) + const currentMails = mailsMapRef.current.get(account.id) || []; + const top5 = currentMails.slice(0, 5); + const isGmail = account.host.toLowerCase().includes('gmail') || account.email.toLowerCase().includes('@gmail'); + const interval = isGmail ? 800 : 100; + top5.forEach((m, i) => { + setTimeout(() => prefetchDetail(account, m), i * interval); + }); + }, + (err) => { + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + setLoadingMoreMap((prev) => new Map(prev).set(account.id, false)); + console.error("스트리밍 오류:", err); + } + ); + return cancel; + } + + function handleFolderClick(folder: string) { + if (!selectedAccount) return; + setCurrentFolder(folder); + setSelectedMail(null); + + if (folder === "INBOX") { + setMailsMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; }); + setMinSeqnoMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; }); + startStream(selectedAccount); + } else { + setMailsMap((prev) => { const n = new Map(prev); n.set(selectedAccount.id, []); return n; }); + setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, true)); + streamFolderMails( + selectedAccount.id, folder, 20, null, + (mail) => { + setMailsMap((prev) => { + const n = new Map(prev); + n.set(selectedAccount.id, [...(n.get(selectedAccount.id) || []), mail]); + return n; + }); + setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); + }, + () => setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)), + (err) => { setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); console.error(err); } + ); + } + } + + async function handleMailClick(mail: ReceivedMail) { + if (!selectedAccount) return; + const seqno = parseInt(mail.id.split("-").pop() || "0"); + + // 캐시 히트 시 즉시 표시 + const cached = detailCacheRef.current.get(mail.id); + if (cached) { + setSelectedMail(cached); + // 첨부파일 로드 + if (seqno) { + } + if (!mail.isRead) { + markUserMailAsRead(selectedAccount.id, seqno).then(() => loadFolders(selectedAccount)).catch(() => {}); + setMailsMap((prev) => { + const next = new Map(prev); + const mails = (next.get(selectedAccount.id) || []).map((m) => + m.id === mail.id ? { ...m, isRead: true } : m + ); + next.set(selectedAccount.id, mails); + mailsMapRef.current = next; + return next; + }); + } + return; + } + + // 캐시 미스 시 fetch + setLoadingDetail(true); + try { + const detail = await getUserMailDetail(selectedAccount.id, seqno); + if (detail) { + detailCacheRef.current.set(mail.id, detail); + setSelectedMail(detail); + } + // 첨부파일 로드 + if (seqno) { + } + if (!mail.isRead) { + await markUserMailAsRead(selectedAccount.id, seqno); + setMailsMap((prev) => { + const next = new Map(prev); + const mails = (next.get(selectedAccount.id) || []).map((m) => + m.id === mail.id ? { ...m, isRead: true } : m + ); + next.set(selectedAccount.id, mails); + mailsMapRef.current = next; + return next; + }); + loadFolders(selectedAccount); + } + } catch (e) { + console.error("메일 상세 로드 실패:", e); + } finally { + setLoadingDetail(false); + } + } + + async function handleDeleteMail(mail: ReceivedMail) { + if (!selectedAccount) return; + if (!confirm("메일을 삭제하시겠습니까?")) return; + const seqno = parseInt(mail.id.split("-").pop() || "0"); + try { + await deleteUserMail(selectedAccount.id, seqno); + setMailsMap((prev) => { + const next = new Map(prev); + const existing = next.get(selectedAccount.id) || []; + next.set(selectedAccount.id, existing.filter((m) => m.id !== mail.id)); + return next; + }); + if (selectedMail?.id === mail.id) setSelectedMail(null); + loadFolders(selectedAccount); + } catch (e: any) { + alert("메일 삭제 실패: " + e.message); + } + } + + async function handleMove(mail: ReceivedMail, targetFolder: string) { + if (!selectedAccount) return; + const seqno = parseInt(mail.id.split("-").pop() || "0"); + try { + await moveUserMail(selectedAccount.id, seqno, targetFolder); + setMailsMap((prev) => { + const next = new Map(prev); + next.set(selectedAccount.id, (next.get(selectedAccount.id) || []).filter((m) => m.id !== mail.id)); + return next; + }); + if (selectedMail?.id === mail.id) setSelectedMail(null); + } catch (e) { + console.error("메일 이동 실패:", e); + } + } + + function handleReply() { + if (!selectedMail) return; + setComposeMode("reply"); + setComposeTo(selectedMail.from); + setComposeSubject(`Re: ${selectedMail.subject.replace(/^Re:\s*/i, "")}`); + setComposeInReplyTo(selectedMail.messageId); + setComposeReferences(selectedMail.messageId); + const dateStr = new Date(selectedMail.date).toLocaleString("ko-KR"); + setComposeInitialHtml( + `

+
${dateStr}, ${selectedMail.from} 작성:
+
${selectedMail.htmlBody || selectedMail.textBody}
+
` + ); + setComposeOpen(true); + } + + function handleForward() { + if (!selectedMail) return; + setComposeMode("forward"); + setComposeTo(""); + setComposeSubject(`Fwd: ${selectedMail.subject.replace(/^Fwd:\s*/i, "")}`); + setComposeInReplyTo(""); + setComposeReferences(""); + setComposeInitialHtml( + `

---------- 전달된 메일 ----------
+
보낸사람: ${selectedMail.from}
+
날짜: ${new Date(selectedMail.date).toLocaleString("ko-KR")}
+
제목: ${selectedMail.subject}
+
받는사람: ${selectedMail.to}
+
${selectedMail.htmlBody || selectedMail.textBody}` + ); + setComposeOpen(true); + } + + function openAddDialog() { + setEditingAccount(null); + setForm(DEFAULT_FORM); + setTestResult(null); + setShowDialog(true); + } + + function openEditDialog(account: UserMailAccount) { + setEditingAccount(account); + setForm({ + displayName: account.displayName, + email: account.email, + protocol: "imap", + host: account.host, + port: account.port, + useTls: account.useTls, + username: account.username, + password: "", + }); + setTestResult(null); + setShowDialog(true); + } + + function handleTlsToggle(checked: boolean) { + setForm((prev) => ({ + ...prev, + useTls: checked, + port: checked ? 993 : 143, + })); + } + + async function handleSave() { + setSaving(true); + setSaveError(null); + try { + if (editingAccount) { + await updateUserMailAccount(editingAccount.id, form); + } else { + await createUserMailAccount(form); + } + await loadAccounts(); + setShowDialog(false); + } catch (e: any) { + const msg = e?.response?.data?.message || (e instanceof Error ? e.message : "저장 실패"); + setSaveError(msg); + } finally { + setSaving(false); + } + } + + async function handleDeleteAccount(account: UserMailAccount) { + if (!confirm(`"${account.displayName}" 계정을 삭제하시겠습니까?`)) return; + try { + await deleteUserMailAccount(account.id); + if (selectedAccount?.id === account.id) { + setSelectedAccount(null); + setSelectedMail(null); + } + setMailsMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; }); + setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; }); + setLoadingMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; }); + setLoadingMoreMap((prev) => { const next = new Map(prev); next.delete(account.id); return next; }); + await loadAccounts(); + } catch (e) { + console.error("계정 삭제 실패:", e); + } + } + + async function handleTest() { + setTesting(true); + setTestResult(null); + try { + const result = await testUserMailConnectionDirect({ + protocol: form.protocol, + host: form.host, + port: form.port, + useTls: form.useTls, + username: form.username, + password: form.password, + }); + setTestResult(result); + } catch (e: unknown) { + setTestResult({ + success: false, + message: e instanceof Error ? e.message : "연결 테스트 실패", + }); + } finally { + setTesting(false); + } + } + + const filteredMails = mails.filter( + (m) => + m.subject.toLowerCase().includes(searchTerm.toLowerCase()) || + m.from.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + function formatDate(dateStr: string) { + const d = new Date(dateStr); + const now = new Date(); + const isToday = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + return isToday + ? d.toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }) + : d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" }); + } + + return ( +
+ {/* 헤더 */} +
+
+ +

메일 관리 (IMAP)

+
+
+ + +
+
+ + {/* 3단 패널 */} +
+ + {/* 계정 목록 */} + +
+
+ + 계정 목록 + {loadingAccounts && } +
+
+ {imapAccounts.length === 0 && !loadingAccounts ? ( +
+ 계정이 없습니다 +
+ ) : ( + imapAccounts.map((account) => ( +
setSelectedAccount(account)} + > +
+
{account.displayName}
+
{account.email}
+
+
+ + +
+
+ )) + )} + {/* 폴더 목록 */} + {selectedAccount && folders.length > 0 && ( +
+
+ 폴더 +
+ {folders.map((folder) => ( +
handleFolderClick(folder.path)} + > + {folder.name} + {folder.unseen > 0 && {folder.unseen}} +
+ ))} +
+ )} +
+
+
+ + + + {/* 메일 목록 */} + +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ {selectedAccount && ( + + )} +
+
+ {!selectedAccount ? ( +
+ +

계정을 선택하세요

+
+ ) : loadingMails && mails.length === 0 ? ( +
+ +
+ ) : filteredMails.length === 0 ? ( +
+ +

메일이 없습니다

+
+ ) : ( + <> + {filteredMails.map((mail) => ( +
handleMailClick(mail)} + onMouseEnter={() => { + if (!selectedAccount || detailCacheRef.current.has(mail.id)) return; + hoverTimerRef.current = setTimeout(() => { + prefetchDetail(selectedAccount, mail); + }, 300); + }} + onMouseLeave={() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + }} + > +
+ {!mail.isRead ? ( +
+ ) : ( +
+ )} +
+
+
+ + {mail.from} + + + {formatDate(mail.date)} + +
+
+ {mail.subject || "(제목 없음)"} +
+
+ + {mail.preview} + + {mail.hasAttachments && ( + + )} +
+
+ +
+ ))} + {loadingMore ? ( +
+ +
+ ) : !loadingMore && mails.length >= 20 ? ( + + ) : null} + + )} +
+
+ + + + + {/* 메일 상세 */} + +
+ {loadingDetail ? ( +
+ +
+ ) : !selectedMail ? ( +
+ +

메일을 선택하세요

+
+ ) : ( + <> +
+

+ {selectedMail.subject || "(제목 없음)"} +

+
+
From: {selectedMail.from}
+
To: {selectedMail.to}
+ {selectedMail.cc && ( +
CC: {selectedMail.cc}
+ )} +
Date: {new Date(selectedMail.date).toLocaleString("ko-KR")}
+
+ {selectedMail.attachments.length > 0 && ( +
+ {selectedMail.attachments.map((att, i) => { + const seqno = selectedMail ? parseInt(selectedMail.id.split("-").pop() || "0") : 0; + const accountId = selectedAccount?.id || 0; + const progress = downloadProgress[i]; + const isDownloading = progress !== undefined; + return ( + + ); + })} +
+ )} + {/* 답장/전달/이동/삭제 버튼 */} +
+ + + + + + + + {folders.filter((f) => f.path !== currentFolder).map((f) => ( + handleMove(selectedMail, f.path)}> + {f.name} + + ))} + + + +
+
+
+ {selectedMail.htmlBody ? ( +