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 ? ( +