feat: 사용자 메일 관리 IMAP 구현
- IMAP 계정 등록/수정/삭제/연결테스트 - SSE 스트리밍으로 메일 목록 로드 (폴더별 지원) - 메일 상세 조회, 읽음 처리, 삭제(휴지통 이동), 폴더 이동 - 첨부파일 다운로드 (ReadableStream 진행바) - SMTP 발송, 답장, 전달 - imapConnectionPool, mailCache 서비스 - encryptionService Node 22+ 호환 수정 - authMiddleware query token 지원 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+213
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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 이력 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -29,9 +29,9 @@ export const authenticateToken = async (
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
@@ -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<any>; resolve: (v: any) => void; reject: (e: any) => void }>;
|
||||
}
|
||||
|
||||
class ImapConnectionPool {
|
||||
private pool = new Map<number, PoolEntry>();
|
||||
private readonly maxIdleMs = 300_000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.cleanupIdle(), 60_000);
|
||||
process.on('SIGTERM', () => this.destroyAll());
|
||||
process.on('SIGINT', () => this.destroyAll());
|
||||
}
|
||||
|
||||
async execute<T>(account: UserMailAccount, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||
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<T>((resolve, reject) => {
|
||||
entry!.queue.push({ fn: fn as any, resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
return this.runWithEntry(entry, fn);
|
||||
}
|
||||
|
||||
private async runWithEntry<T>(entry: PoolEntry, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
||||
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();
|
||||
@@ -0,0 +1,43 @@
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MailCache {
|
||||
private cache = new Map<string, CacheEntry<any>>();
|
||||
private readonly maxEntries = 1000;
|
||||
|
||||
constructor() {
|
||||
setInterval(() => this.sweep(), 60_000);
|
||||
}
|
||||
|
||||
get<T>(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<T>(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();
|
||||
@@ -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<UserMailAccount[]> {
|
||||
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<UserMailAccount | null> {
|
||||
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<UserMailAccount> {
|
||||
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<CreateUserMailAccountDto>): Promise<UserMailAccount | null> {
|
||||
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<boolean> {
|
||||
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();
|
||||
@@ -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<ReceivedMail[]> {
|
||||
const cacheKey = `mailList:${account.id}:INBOX:${limit}`;
|
||||
const cached = mailCache.get<ReceivedMail[]>(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<void> {
|
||||
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<MailDetail | null> {
|
||||
const cacheKey = `mailDetail:${account.id}:${seqno}`;
|
||||
const cached = mailCache.get<MailDetail>(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<Array<{ path: string; name: string; unseen: number; }>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Array<{ partId: string; filename: string; contentType: string; size: number }>> {
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "reply" ? "답장" : mode === "forward" ? "전달" : "새 메일"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>받는사람</Label>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} placeholder="to@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>참조(CC)</Label>
|
||||
<Input value={cc} onChange={(e) => setCc(e.target.value)} placeholder="cc@example.com (선택)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>내용</Label>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
|
||||
<Button onClick={handleSend} disabled={sending || !to || !subject}>
|
||||
{sending ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Send className="w-4 h-4 mr-2" />}
|
||||
보내기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<UserMailAccount[]>([]);
|
||||
const [selectedAccount, setSelectedAccount] = useState<UserMailAccount | null>(null);
|
||||
const [mailsMap, setMailsMap] = useState<Map<number, ReceivedMail[]>>(new Map());
|
||||
const [loadingMap, setLoadingMap] = useState<Map<number, boolean>>(new Map());
|
||||
const [minSeqnoMap, setMinSeqnoMap] = useState<Map<number, number | null>>(new Map());
|
||||
const [loadingMoreMap, setLoadingMoreMap] = useState<Map<number, boolean>>(new Map());
|
||||
const [selectedMail, setSelectedMail] = useState<MailDetail | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<Record<number, number>>({});
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingAccount, setEditingAccount] = useState<UserMailAccount | null>(null);
|
||||
const [form, setForm] = useState<CreateUserMailAccountDto>(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<string | null>(null);
|
||||
|
||||
// New states
|
||||
const [folders, setFolders] = useState<MailFolder[]>([]);
|
||||
const [currentFolder, setCurrentFolder] = useState<string>("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<Map<string, MailDetail>>(new Map());
|
||||
const prefetchingRef = useRef<Set<string>>(new Set());
|
||||
const mailsMapRef = useRef<Map<number, ReceivedMail[]>>(new Map());
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(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(
|
||||
`<br><br><div style="border-left:2px solid #ccc;padding-left:8px;color:#666">
|
||||
<div>${dateStr}, ${selectedMail.from} 작성:</div>
|
||||
<blockquote>${selectedMail.htmlBody || selectedMail.textBody}</blockquote>
|
||||
</div>`
|
||||
);
|
||||
setComposeOpen(true);
|
||||
}
|
||||
|
||||
function handleForward() {
|
||||
if (!selectedMail) return;
|
||||
setComposeMode("forward");
|
||||
setComposeTo("");
|
||||
setComposeSubject(`Fwd: ${selectedMail.subject.replace(/^Fwd:\s*/i, "")}`);
|
||||
setComposeInReplyTo("");
|
||||
setComposeReferences("");
|
||||
setComposeInitialHtml(
|
||||
`<br><br><div>---------- 전달된 메일 ----------</div>
|
||||
<div>보낸사람: ${selectedMail.from}</div>
|
||||
<div>날짜: ${new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
|
||||
<div>제목: ${selectedMail.subject}</div>
|
||||
<div>받는사람: ${selectedMail.to}</div>
|
||||
<br>${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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">메일 관리 (IMAP)</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => { setComposeMode("new"); setComposeInitialHtml(""); setComposeTo(""); setComposeCc(""); setComposeSubject(""); setComposeOpen(true); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
작성
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAddDialog}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
계정 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3단 패널 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* 계정 목록 */}
|
||||
<ResizablePanel defaultSize={18} minSize={14}>
|
||||
<div className="flex flex-col h-full border-r">
|
||||
<div className="px-3 py-2 border-b text-xs font-medium text-muted-foreground flex items-center gap-1">
|
||||
<Inbox className="h-3.5 w-3.5" />
|
||||
계정 목록
|
||||
{loadingAccounts && <Loader2 className="h-3 w-3 animate-spin ml-auto" />}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{imapAccounts.length === 0 && !loadingAccounts ? (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
계정이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
imapAccounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className={`group flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b text-sm transition-colors ${
|
||||
selectedAccount?.id === account.id ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedAccount(account)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{account.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{account.email}</div>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
className="p-0.5 hover:text-foreground text-muted-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); openEditDialog(account); }}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-0.5 hover:text-destructive text-muted-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteAccount(account); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{/* 폴더 목록 */}
|
||||
{selectedAccount && folders.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs font-semibold text-muted-foreground px-2 mb-1 flex items-center gap-1">
|
||||
<FolderOpen className="w-3 h-3" /> 폴더
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<div
|
||||
key={folder.path}
|
||||
className={`px-2 py-1 text-sm cursor-pointer rounded hover:bg-accent flex justify-between items-center ${currentFolder === folder.path ? "bg-accent font-medium" : ""}`}
|
||||
onClick={() => handleFolderClick(folder.path)}
|
||||
>
|
||||
<span>{folder.name}</span>
|
||||
{folder.unseen > 0 && <Badge variant="secondary" className="text-xs">{folder.unseen}</Badge>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 메일 목록 */}
|
||||
<ResizablePanel defaultSize={32} minSize={22}>
|
||||
<div className="flex flex-col h-full border-r">
|
||||
<div className="px-3 py-2 border-b flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-7 h-7 text-xs"
|
||||
placeholder="제목, 발신자 검색"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{selectedAccount && (
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
if (selectedAccount) {
|
||||
setMailsMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
|
||||
setMinSeqnoMap((prev) => { const next = new Map(prev); next.delete(selectedAccount.id); return next; });
|
||||
if (currentFolder === "INBOX") {
|
||||
startStream(selectedAccount);
|
||||
} else {
|
||||
handleFolderClick(currentFolder);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!selectedAccount ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Mail className="h-8 w-8 opacity-30" />
|
||||
<p className="text-sm">계정을 선택하세요</p>
|
||||
</div>
|
||||
) : loadingMails && mails.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredMails.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Inbox className="h-8 w-8 opacity-30" />
|
||||
<p className="text-sm">메일이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredMails.map((mail) => (
|
||||
<div
|
||||
key={mail.id}
|
||||
className={`group flex items-start gap-2 px-3 py-2.5 cursor-pointer hover:bg-muted/50 border-b transition-colors ${
|
||||
selectedMail?.id === mail.id ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => 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);
|
||||
}}
|
||||
>
|
||||
<div className="mt-1">
|
||||
{!mail.isRead ? (
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-transparent border border-muted-foreground/30" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className={`text-xs truncate ${!mail.isRead ? "font-semibold" : "text-muted-foreground"}`}>
|
||||
{mail.from}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatDate(mail.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm truncate ${!mail.isRead ? "font-medium" : ""}`}>
|
||||
{mail.subject || "(제목 없음)"}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs text-muted-foreground truncate flex-1">
|
||||
{mail.preview}
|
||||
</span>
|
||||
{mail.hasAttachments && (
|
||||
<Paperclip className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive text-muted-foreground mt-1"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteMail(mail); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{loadingMore ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !loadingMore && mails.length >= 20 ? (
|
||||
<button
|
||||
className="w-full py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
onClick={() => {
|
||||
if (selectedAccount) {
|
||||
setLoadingMoreMap((prev) => new Map(prev).set(selectedAccount.id, true));
|
||||
startStream(selectedAccount, minSeqno, true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
이전 메일 더 보기
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
{/* 메일 상세 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
{loadingDetail ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !selectedMail ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<ChevronRight className="h-8 w-8 opacity-30" />
|
||||
<p className="text-sm">메일을 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="px-4 py-3 border-b space-y-1">
|
||||
<h2 className="font-semibold text-base">
|
||||
{selectedMail.subject || "(제목 없음)"}
|
||||
</h2>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<div><span className="font-medium">From:</span> {selectedMail.from}</div>
|
||||
<div><span className="font-medium">To:</span> {selectedMail.to}</div>
|
||||
{selectedMail.cc && (
|
||||
<div><span className="font-medium">CC:</span> {selectedMail.cc}</div>
|
||||
)}
|
||||
<div><span className="font-medium">Date:</span> {new Date(selectedMail.date).toLocaleString("ko-KR")}</div>
|
||||
</div>
|
||||
{selectedMail.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{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 (
|
||||
<button key={i}
|
||||
disabled={isDownloading}
|
||||
onClick={async () => {
|
||||
setDownloadProgress(p => ({ ...p, [i]: 0 }));
|
||||
try {
|
||||
const list = await getUserMailAttachments(accountId, seqno, currentFolder);
|
||||
const matched = list.find(a => a.filename === att.filename) || list[i];
|
||||
if (!matched) { alert('첨부파일 정보를 불러올 수 없습니다'); return; }
|
||||
await downloadAttachment(
|
||||
accountId, seqno, matched.partId, matched.filename, currentFolder,
|
||||
(pct) => setDownloadProgress(p => ({ ...p, [i]: pct })),
|
||||
matched.size
|
||||
);
|
||||
} catch (e: any) { alert(e.message); }
|
||||
finally { setDownloadProgress(p => { const next = { ...p }; delete next[i]; return next; }); }
|
||||
}}
|
||||
className="relative inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-secondary hover:bg-accent border disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer min-w-[80px] overflow-hidden">
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
|
||||
<span className="flex-1 truncate">{att.filename}</span>
|
||||
<span className="shrink-0 font-mono">{progress}%</span>
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-primary/30 overflow-hidden">
|
||||
<span className="block h-full bg-primary transition-all duration-200" style={{ width: `${progress}%` }} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{att.filename}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{/* 답장/전달/이동/삭제 버튼 */}
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={handleReply}>
|
||||
<Reply className="w-3 h-3 mr-1" /> 답장
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleForward}>
|
||||
<Forward className="w-3 h-3 mr-1" /> 전달
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<FolderOpen className="w-3 h-3 mr-1" /> 이동
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{folders.filter((f) => f.path !== currentFolder).map((f) => (
|
||||
<DropdownMenuItem key={f.path} onClick={() => handleMove(selectedMail, f.path)}>
|
||||
{f.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeleteMail(selectedMail)}>
|
||||
<Trash2 className="w-3 h-3 mr-1" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedMail.htmlBody ? (
|
||||
<iframe
|
||||
srcDoc={DOMPurify.sanitize(selectedMail.htmlBody)}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-same-origin"
|
||||
title="메일 본문"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 overflow-y-auto h-full">
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans">
|
||||
{selectedMail.textBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ComposeDialog */}
|
||||
<ComposeDialogDynamic
|
||||
open={composeOpen}
|
||||
onOpenChange={setComposeOpen}
|
||||
mode={composeMode}
|
||||
to={composeTo} setTo={setComposeTo}
|
||||
cc={composeCc} setCc={setComposeCc}
|
||||
subject={composeSubject} setSubject={setComposeSubject}
|
||||
initialHtml={composeInitialHtml} setInitialHtml={setComposeInitialHtml}
|
||||
inReplyTo={composeInReplyTo}
|
||||
references={composeReferences}
|
||||
sending={composeSending} setSending={setComposeSending}
|
||||
accountId={selectedAccount?.id ?? null}
|
||||
/>
|
||||
|
||||
{/* 계정 추가/편집 다이얼로그 */}
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingAccount ? "IMAP 계정 편집" : "IMAP 계정 추가"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
placeholder="내 Gmail"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">이메일 주소</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">IMAP 호스트</Label>
|
||||
<Input
|
||||
value={form.host}
|
||||
onChange={(e) => setForm((p) => ({ ...p, host: e.target.value }))}
|
||||
placeholder="imap.gmail.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">포트</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => setForm((p) => ({ ...p, port: parseInt(e.target.value) || 993 }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">TLS/SSL 사용</Label>
|
||||
<Switch checked={form.useTls} onCheckedChange={handleTlsToggle} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">사용자명</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={(e) => setForm((p) => ({ ...p, username: e.target.value }))}
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">비밀번호 {editingAccount && "(변경 시에만 입력)"}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((p) => ({ ...p, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{editingAccount && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
) : null}
|
||||
연결 테스트
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-2 text-xs flex items-center gap-1 ${testResult.success ? "text-green-600" : "text-red-600"}`}>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{saveError && (
|
||||
<div className="px-6 pb-2 text-sm text-red-600">{saveError}</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowDialog(false)}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/mail/imap": dynamic(() => import("@/app/(main)/mail/imap/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 배치 관리
|
||||
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { apiClient, API_BASE_URL } from "./client";
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: { method?: string; data?: unknown } = {}
|
||||
): Promise<T> {
|
||||
const { method = "GET", data } = options;
|
||||
|
||||
try {
|
||||
const response = await apiClient({
|
||||
url: endpoint,
|
||||
method,
|
||||
data,
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data !== undefined) {
|
||||
return response.data.data as T;
|
||||
}
|
||||
|
||||
return response.data as T;
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && "response" in error) {
|
||||
const axiosError = error as {
|
||||
response?: { data?: { message?: string }; status?: number };
|
||||
};
|
||||
throw new Error(
|
||||
axiosError.response?.data?.message || `HTTP ${axiosError.response?.status}`
|
||||
);
|
||||
}
|
||||
throw new Error("Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
export interface UserMailAccount {
|
||||
id: number;
|
||||
displayName: string;
|
||||
email: string;
|
||||
protocol: "imap" | "pop3";
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
username: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateUserMailAccountDto {
|
||||
displayName: string;
|
||||
email: string;
|
||||
protocol: "imap" | "pop3";
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ReceivedMail {
|
||||
id: string;
|
||||
messageId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
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 }>;
|
||||
}
|
||||
|
||||
export async function getUserMailAccounts(): Promise<UserMailAccount[]> {
|
||||
return fetchApi<UserMailAccount[]>("/user-mail/accounts");
|
||||
}
|
||||
|
||||
export async function createUserMailAccount(
|
||||
dto: CreateUserMailAccountDto
|
||||
): Promise<UserMailAccount> {
|
||||
return fetchApi<UserMailAccount>("/user-mail/accounts", {
|
||||
method: "POST",
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUserMailAccount(
|
||||
id: number,
|
||||
dto: Partial<CreateUserMailAccountDto>
|
||||
): Promise<UserMailAccount> {
|
||||
return fetchApi<UserMailAccount>(`/user-mail/accounts/${id}`, {
|
||||
method: "PUT",
|
||||
data: dto,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUserMailAccount(id: number): Promise<void> {
|
||||
return fetchApi<void>(`/user-mail/accounts/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function testUserMailAccount(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/user-mail/accounts/${id}/test`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function testUserMailConnectionDirect(dto: {
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
useTls: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/user-mail/test-connection`,
|
||||
{ method: "POST", data: dto }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMails(
|
||||
accountId: number,
|
||||
limit: number = 50
|
||||
): Promise<ReceivedMail[]> {
|
||||
return fetchApi<ReceivedMail[]>(
|
||||
`/user-mail/accounts/${accountId}/mails?limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMailDetail(
|
||||
accountId: number,
|
||||
seqno: number
|
||||
): Promise<MailDetail> {
|
||||
return fetchApi<MailDetail>(
|
||||
`/user-mail/accounts/${accountId}/mails/${seqno}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function markUserMailAsRead(
|
||||
accountId: number,
|
||||
seqno: number
|
||||
): Promise<void> {
|
||||
return fetchApi<void>(
|
||||
`/user-mail/accounts/${accountId}/mails/${seqno}/mark-read`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUserMail(
|
||||
accountId: number,
|
||||
seqno: number
|
||||
): Promise<void> {
|
||||
return fetchApi<void>(`/user-mail/accounts/${accountId}/mails/${seqno}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function streamUserMails(
|
||||
accountId: number,
|
||||
limit: number = 20,
|
||||
before: number | null = null,
|
||||
onMail: (mail: ReceivedMail) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: string) => void
|
||||
): () => void {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("authToken") : null;
|
||||
const url = before
|
||||
? `${API_BASE_URL}/user-mail/accounts/${accountId}/mails/stream?limit=${limit}&before=${before}`
|
||||
: `${API_BASE_URL}/user-mail/accounts/${accountId}/mails/stream?limit=${limit}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
onError("스트리밍 연결 실패");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "message";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
if (currentEvent === "done") onDone();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
if (currentEvent === "message") {
|
||||
try {
|
||||
const mail = JSON.parse(line.slice(6));
|
||||
if (mail?.id) onMail(mail);
|
||||
} catch {}
|
||||
} else if (currentEvent === "error") {
|
||||
try {
|
||||
const { message } = JSON.parse(line.slice(6));
|
||||
onError(message || "스트리밍 오류");
|
||||
} catch {}
|
||||
}
|
||||
currentEvent = "message"; // 리셋
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== "AbortError") onError(e.message || "연결 오류");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
export interface MailFolder {
|
||||
path: string;
|
||||
name: string;
|
||||
unseen: number;
|
||||
}
|
||||
|
||||
export interface SendMailDto {
|
||||
to: string;
|
||||
cc?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
}
|
||||
|
||||
export interface MailAttachment {
|
||||
partId: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function getUserMailFolders(accountId: number): Promise<MailFolder[]> {
|
||||
return fetchApi<MailFolder[]>(`/user-mail/accounts/${accountId}/folders`);
|
||||
}
|
||||
|
||||
export async function moveUserMail(
|
||||
accountId: number,
|
||||
seqno: number,
|
||||
targetFolder: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/user-mail/accounts/${accountId}/mails/${seqno}/move`,
|
||||
{ method: 'POST', data: { targetFolder } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendUserMail(
|
||||
accountId: number,
|
||||
dto: SendMailDto
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/user-mail/accounts/${accountId}/send`,
|
||||
{ method: 'POST', data: dto }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMailAttachments(
|
||||
accountId: number,
|
||||
seqno: number,
|
||||
folder: string = 'INBOX'
|
||||
): Promise<MailAttachment[]> {
|
||||
return fetchApi<MailAttachment[]>(
|
||||
`/user-mail/accounts/${accountId}/mails/${seqno}/attachments?folder=${encodeURIComponent(folder)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadAttachment(
|
||||
accountId: number,
|
||||
seqno: number,
|
||||
partId: string,
|
||||
filename: string,
|
||||
folder: string = 'INBOX',
|
||||
onProgress?: (percent: number) => void,
|
||||
totalSize?: number
|
||||
): Promise<void> {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
const url = `${API_BASE_URL}/user-mail/accounts/${accountId}/mails/${seqno}/attachment/${encodeURIComponent(partId)}?folder=${encodeURIComponent(folder)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error(`다운로드 실패: ${res.status}`);
|
||||
|
||||
const contentLength = totalSize || parseInt(res.headers.get('content-length') || '0');
|
||||
|
||||
if (contentLength && onProgress && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.length;
|
||||
onProgress(Math.min(99, Math.round((received / contentLength) * 100)));
|
||||
}
|
||||
onProgress(100);
|
||||
const blob = new Blob(chunks);
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function streamFolderMails(
|
||||
accountId: number,
|
||||
folder: string,
|
||||
limit: number = 20,
|
||||
before: number | null = null,
|
||||
onMail: (mail: ReceivedMail) => void,
|
||||
onDone: () => void,
|
||||
onError: (err: string) => void
|
||||
): () => void {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
const encodedFolder = encodeURIComponent(folder);
|
||||
const url = before
|
||||
? `${API_BASE_URL}/user-mail/accounts/${accountId}/folders/${encodedFolder}/mails/stream?limit=${limit}&before=${before}`
|
||||
: `${API_BASE_URL}/user-mail/accounts/${accountId}/folders/${encodedFolder}/mails/stream?limit=${limit}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) { onError('스트리밍 연결 실패'); return; }
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
let currentEvent = 'message';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
if (currentEvent === 'done') onDone();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
if (currentEvent === 'message') {
|
||||
try { const mail = JSON.parse(line.slice(6)); if (mail?.id) onMail(mail); } catch {}
|
||||
} else if (currentEvent === 'error') {
|
||||
try { const { message } = JSON.parse(line.slice(6)); onError(message || '오류'); } catch {}
|
||||
}
|
||||
currentEvent = 'message';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') onError(e.message || '연결 오류');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
Generated
+150
-113
@@ -35,12 +35,14 @@
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-image": "^3.20.5",
|
||||
"@tiptap/extension-link": "^3.20.5",
|
||||
"@tiptap/extension-placeholder": "^2.27.1",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
"@tiptap/react": "^2.27.1",
|
||||
"@tiptap/starter-kit": "^2.27.1",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"@turf/buffer": "^7.2.0",
|
||||
"@turf/helpers": "^7.2.0",
|
||||
"@turf/intersect": "^7.2.0",
|
||||
@@ -1415,9 +1417,8 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
@@ -3776,12 +3777,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.22",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.22.tgz",
|
||||
"integrity": "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA==",
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
|
||||
"integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.22"
|
||||
"@tanstack/virtual-core": "3.13.23"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3806,9 +3807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.22",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.22.tgz",
|
||||
"integrity": "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g==",
|
||||
"version": "3.13.23",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz",
|
||||
"integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3816,9 +3817,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
|
||||
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3829,9 +3830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.1.tgz",
|
||||
"integrity": "sha512-QrUX3muElDrNjKM3nqCSAtm3H3pT33c6ON8kwRiQboOAjT/9D57Cs7XEVY7r6rMaJPeKztrRUrNVF9w/w/6B0A==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
|
||||
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3842,9 +3843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.1.tgz",
|
||||
"integrity": "sha512-g4l4p892x/r7mhea8syp3fNYODxsDrimgouQ+q4DKXIgQmm5+uNhyuEPexP3I8TFNXqQ4DlMNFoM9yCqk97etQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
|
||||
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3855,9 +3856,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.1.tgz",
|
||||
"integrity": "sha512-ki1R27VsSvY2tT9Q2DIlcATwLOoEjf5DsN+5sExarQ8S/ZxT/tvIjRxB8Dx7lb2a818W5f/NER26YchGtmHfpg==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
|
||||
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
@@ -3872,9 +3873,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.1.tgz",
|
||||
"integrity": "sha512-5FmnfXkJ76wN4EbJNzBhAlmQxho8yEMIJLchTGmXdsD/n/tsyVVtewnQYaIOj/Z7naaGySTGDmjVtLgTuQ+Sxw==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
|
||||
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3885,9 +3886,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.1.tgz",
|
||||
"integrity": "sha512-i65wUGJevzBTIIUBHBc1ggVa27bgemvGl/tY1/89fEuS/0Xmre+OQjw8rCtSLevoHSiYYLgLRlvjtUSUhE4kgg==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
|
||||
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3898,9 +3899,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
|
||||
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
|
||||
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3912,9 +3913,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.1.tgz",
|
||||
"integrity": "sha512-NtJzJY7Q/6XWjpOm5OXKrnEaofrcc1XOTYlo/SaTwl8k2bZo918Vl0IDBWhPVDsUN7kx767uHwbtuQZ+9I82hA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
|
||||
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3925,9 +3926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.1.tgz",
|
||||
"integrity": "sha512-3MBQRGHHZ0by3OT0CWbLKS7J3PH9PpobrXjmIR7kr0nde7+bHqxXiVNuuIf501oKU9rnEUSedipSHkLYGkmfsA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
|
||||
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3939,9 +3940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.1.tgz",
|
||||
"integrity": "sha512-nUk/8DbiXO69l6FDwkWso94BTf52IBoWALo+YGWT6o+FO6cI9LbUGghEX2CdmQYXCvSvwvISF2jXeLQWNZvPZQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
|
||||
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tippy.js": "^6.3.7"
|
||||
@@ -3956,9 +3957,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.1.tgz",
|
||||
"integrity": "sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
|
||||
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3970,9 +3971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.1.tgz",
|
||||
"integrity": "sha512-W4hHa4Io6QCTwpyTlN6UAvqMIQ7t56kIUByZhyY9EWrg/+JpbfpxE1kXFLPB4ZGgwBknFOw+e4bJ1j3oAbTJFw==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
|
||||
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3983,9 +3984,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.1.tgz",
|
||||
"integrity": "sha512-6xoC7igZlW1EmnQ5WVH9IL7P1nCQb3bBUaIDLvk7LbweEogcTUECI4Xg1vxMOVmj9tlDe1I4BsgfcKpB5KEsZw==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
|
||||
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3996,9 +3997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-history": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.1.tgz",
|
||||
"integrity": "sha512-K8PHC9gegSAt0wzSlsd4aUpoEyIJYOmVVeyniHr1P1mIblW1KYEDbRGbDlrLALTyUEfMcBhdIm8zrB9X2Nihvg==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
|
||||
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4010,9 +4011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.1.tgz",
|
||||
"integrity": "sha512-WxXWGEEsqDmGIF2o9av+3r9Qje4CKrqrpeQY6aRO5bxvWX9AabQCfasepayBok6uwtvNzh3Xpsn9zbbSk09dNA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
|
||||
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4023,10 +4024,23 @@
|
||||
"@tiptap/pm": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.5.tgz",
|
||||
"integrity": "sha512-qxKupWKhX75Xc9GJ9Uel+KIFL9x6tb8W3RvQM1UolyJX/H7wyBO7sXp9XmKRkHZsDXRgLVbnkYBe+X83o16AIA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.20.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.1.tgz",
|
||||
"integrity": "sha512-rcm0GyniWW0UhcNI9+1eIK64GqWQLyIIrWGINslvqSUoBc+WkfocLvv4CMpRkzKlfsAxwVIBuH2eLxHKDtAREA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
|
||||
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4036,10 +4050,27 @@
|
||||
"@tiptap/core": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.5.tgz",
|
||||
"integrity": "sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.20.5",
|
||||
"@tiptap/pm": "^3.20.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.1.tgz",
|
||||
"integrity": "sha512-dtsxvtzxfwOJP6dKGf0vb2MJAoDF2NxoiWzpq0XTvo7NGGYUHfuHjX07Zp0dYqb4seaDXjwsi5BIQUOp3+WMFQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
|
||||
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4050,9 +4081,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.1.tgz",
|
||||
"integrity": "sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
|
||||
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4063,9 +4094,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.1.tgz",
|
||||
"integrity": "sha512-R3QdrHcUdFAsdsn2UAIvhY0yWyHjqGyP/Rv8RRdN0OyFiTKtwTPqreKMHKJOflgX4sMJl/OpHTpNG1Kaf7Lo2A==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
|
||||
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4090,9 +4121,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.1.tgz",
|
||||
"integrity": "sha512-S9I//K8KPgfFTC5I5lorClzXk0g4lrAv9y5qHzHO5EOWt7AFl0YTg2oN8NKSIBK4bHRnPIrjJJKv+dDFnUp5jQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
|
||||
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4103,9 +4134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.1.tgz",
|
||||
"integrity": "sha512-a4GCT+GZ9tUwl82F4CEum9/+WsuW0/De9Be/NqrMmi7eNfAwbUTbLCTFU0gEvv25WMHCoUzaeNk/qGmzeVPJ1Q==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
|
||||
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4116,9 +4147,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.1.tgz",
|
||||
"integrity": "sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
|
||||
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4129,9 +4160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
|
||||
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
@@ -4159,13 +4190,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.1.tgz",
|
||||
"integrity": "sha512-leJximSjYJuhLJQv9azOP9R7w6zuxVgKOHYT4w83Gte7GhWMpNL6xRWzld280vyq/YW/cSYjPb/8ESEOgKNBdQ==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
|
||||
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^2.27.1",
|
||||
"@tiptap/extension-floating-menu": "^2.27.1",
|
||||
"@tiptap/extension-bubble-menu": "^2.27.2",
|
||||
"@tiptap/extension-floating-menu": "^2.27.2",
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-deep-equal": "^3",
|
||||
"use-sync-external-store": "^1"
|
||||
@@ -4182,32 +4213,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "2.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.1.tgz",
|
||||
"integrity": "sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==",
|
||||
"version": "2.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
|
||||
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-blockquote": "^2.27.1",
|
||||
"@tiptap/extension-bold": "^2.27.1",
|
||||
"@tiptap/extension-bullet-list": "^2.27.1",
|
||||
"@tiptap/extension-code": "^2.27.1",
|
||||
"@tiptap/extension-code-block": "^2.27.1",
|
||||
"@tiptap/extension-document": "^2.27.1",
|
||||
"@tiptap/extension-dropcursor": "^2.27.1",
|
||||
"@tiptap/extension-gapcursor": "^2.27.1",
|
||||
"@tiptap/extension-hard-break": "^2.27.1",
|
||||
"@tiptap/extension-heading": "^2.27.1",
|
||||
"@tiptap/extension-history": "^2.27.1",
|
||||
"@tiptap/extension-horizontal-rule": "^2.27.1",
|
||||
"@tiptap/extension-italic": "^2.27.1",
|
||||
"@tiptap/extension-list-item": "^2.27.1",
|
||||
"@tiptap/extension-ordered-list": "^2.27.1",
|
||||
"@tiptap/extension-paragraph": "^2.27.1",
|
||||
"@tiptap/extension-strike": "^2.27.1",
|
||||
"@tiptap/extension-text": "^2.27.1",
|
||||
"@tiptap/extension-text-style": "^2.27.1",
|
||||
"@tiptap/pm": "^2.27.1"
|
||||
"@tiptap/core": "^2.27.2",
|
||||
"@tiptap/extension-blockquote": "^2.27.2",
|
||||
"@tiptap/extension-bold": "^2.27.2",
|
||||
"@tiptap/extension-bullet-list": "^2.27.2",
|
||||
"@tiptap/extension-code": "^2.27.2",
|
||||
"@tiptap/extension-code-block": "^2.27.2",
|
||||
"@tiptap/extension-document": "^2.27.2",
|
||||
"@tiptap/extension-dropcursor": "^2.27.2",
|
||||
"@tiptap/extension-gapcursor": "^2.27.2",
|
||||
"@tiptap/extension-hard-break": "^2.27.2",
|
||||
"@tiptap/extension-heading": "^2.27.2",
|
||||
"@tiptap/extension-history": "^2.27.2",
|
||||
"@tiptap/extension-horizontal-rule": "^2.27.2",
|
||||
"@tiptap/extension-italic": "^2.27.2",
|
||||
"@tiptap/extension-list-item": "^2.27.2",
|
||||
"@tiptap/extension-ordered-list": "^2.27.2",
|
||||
"@tiptap/extension-paragraph": "^2.27.2",
|
||||
"@tiptap/extension-strike": "^2.27.2",
|
||||
"@tiptap/extension-text": "^2.27.2",
|
||||
"@tiptap/extension-text-style": "^2.27.2",
|
||||
"@tiptap/pm": "^2.27.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -6641,7 +6672,7 @@
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -12065,6 +12096,12 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
|
||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/listenercount": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
|
||||
@@ -12961,7 +12998,7 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
@@ -12980,7 +13017,7 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -44,12 +44,14 @@
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.86.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.22",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"@tiptap/core": "^2.27.1",
|
||||
"@tiptap/extension-image": "^3.20.5",
|
||||
"@tiptap/extension-link": "^3.20.5",
|
||||
"@tiptap/extension-placeholder": "^2.27.1",
|
||||
"@tiptap/pm": "^2.27.1",
|
||||
"@tiptap/react": "^2.27.1",
|
||||
"@tiptap/starter-kit": "^2.27.1",
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"@turf/buffer": "^7.2.0",
|
||||
"@turf/helpers": "^7.2.0",
|
||||
"@turf/intersect": "^7.2.0",
|
||||
|
||||
Reference in New Issue
Block a user