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:
syc0123
2026-03-30 17:17:20 +09:00
parent 767863b6d6
commit 4c42cc7b53
19 changed files with 3011 additions and 120 deletions
+213
View File
@@ -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",
+2
View File
@@ -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",
+8
View File
@@ -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();
+29
View File
@@ -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({
+26
View File
@@ -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();
+43
View File
@@ -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>
);
}
+963
View File
@@ -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 }),
+398
View File
@@ -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();
}
+150 -113
View File
@@ -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"
+5 -3
View File
@@ -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",