diff --git a/.gitignore b/.gitignore index a766194f..e2062811 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,9 @@ settings/ *.crt *.cert secrets/ + +# oh-my-claudecode 로컬 세션/상태 파일 +.omc/ secrets.json secrets.yaml secrets.yml @@ -231,3 +234,4 @@ test-results/ frontend/playwright.config.ts frontend/tests/ frontend/test-results/ +db/checkpoints/ diff --git a/.omc/project-memory.json b/.omc/project-memory.json deleted file mode 100644 index 80e41159..00000000 --- a/.omc/project-memory.json +++ /dev/null @@ -1,447 +0,0 @@ -{ - "version": "1.0.0", - "lastScanned": 1774313213052, - "projectRoot": "/Users/kimjuseok/ERP-node", - "techStack": { - "languages": [ - { - "name": "JavaScript/TypeScript", - "version": null, - "confidence": "high", - "markers": [ - "package.json" - ] - } - ], - "frameworks": [ - { - "name": "playwright", - "version": "1.58.2", - "category": "testing" - } - ], - "packageManager": "npm", - "runtime": null - }, - "build": { - "buildCommand": null, - "testCommand": null, - "lintCommand": null, - "devCommand": null, - "scripts": {} - }, - "conventions": { - "namingStyle": null, - "importStyle": null, - "testPattern": null, - "fileOrganization": null - }, - "structure": { - "isMonorepo": false, - "workspaces": [], - "mainDirectories": [ - "docs", - "scripts" - ], - "gitBranches": { - "defaultBranch": "main", - "branchingStrategy": null - } - }, - "customNotes": [], - "directoryMap": { - "_local": { - "path": "_local", - "purpose": null, - "fileCount": 1, - "lastAccessed": 1774313213033, - "keyFiles": [ - "pipeline-progress.json" - ] - }, - "ai-assistant": { - "path": "ai-assistant", - "purpose": null, - "fileCount": 5, - "lastAccessed": 1774313213036, - "keyFiles": [ - "Dockerfile.win", - "README.md", - "package-lock.json", - "package.json" - ] - }, - "backend": { - "path": "backend", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213038, - "keyFiles": [] - }, - "backend-node": { - "path": "backend-node", - "purpose": null, - "fileCount": 17, - "lastAccessed": 1774313213039, - "keyFiles": [ - "API_연동_가이드.md", - "API_키_정리.md", - "Dockerfile.win", - "PHASE1_USAGE_GUIDE.md", - "README.md" - ] - }, - "backup": { - "path": "backup", - "purpose": null, - "fileCount": 6, - "lastAccessed": 1774313213040, - "keyFiles": [ - "Dockerfile", - "README.md", - "backup.py", - "docker-compose.backup.yml" - ] - }, - "db": { - "path": "db", - "purpose": null, - "fileCount": 14, - "lastAccessed": 1774313213041, - "keyFiles": [ - "00-create-roles.sh", - "check_category_values.sql", - "check_numbering_rules.sql", - "cleanup_duplicate_screens_daejin.sql", - "company7_screen_backup.sql" - ] - }, - "deploy": { - "path": "deploy", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213041, - "keyFiles": [] - }, - "digitalTwin": { - "path": "digitalTwin", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213041, - "keyFiles": [ - "architecture-v4.md", - "fleet-management-plan.md", - "디지털트윈 아키텍쳐_v3.png", - "디지털트윈 아키텍쳐_v4.png" - ] - }, - "docker": { - "path": "docker", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213042, - "keyFiles": [] - }, - "docs": { - "path": "docs", - "purpose": "Documentation", - "fileCount": 35, - "lastAccessed": 1774313213042, - "keyFiles": [ - "AI_화면생성_시스템_설계서.md", - "BOM_개발_현황.md", - "DB_ARCHITECTURE_ANALYSIS.md", - "DB_STRUCTURE_DIAGRAM.html", - "DB_WORKFLOW_ANALYSIS.md" - ] - }, - "frontend": { - "path": "frontend", - "purpose": null, - "fileCount": 17, - "lastAccessed": 1774313213043, - "keyFiles": [ - "MODAL_REPEATER_TABLE_DEBUG.md", - "README.md", - "approval-box-result.png", - "components.json", - "eslint.config.mjs" - ] - }, - "k8s": { - "path": "k8s", - "purpose": null, - "fileCount": 7, - "lastAccessed": 1774313213043, - "keyFiles": [ - "local-path-provisioner.yaml", - "namespace.yaml", - "vexplor-backend-deployment.yaml", - "vexplor-config.yaml", - "vexplor-frontend-deployment.yaml" - ] - }, - "mcp-agent-orchestrator": { - "path": "mcp-agent-orchestrator", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213043, - "keyFiles": [ - "README.md", - "package-lock.json", - "package.json", - "tsconfig.json" - ] - }, - "mcp-task-queue": { - "path": "mcp-task-queue", - "purpose": null, - "fileCount": 4, - "lastAccessed": 1774313213043, - "keyFiles": [ - "package-lock.json", - "package.json", - "tsconfig.json" - ] - }, - "mcp-task-server": { - "path": "mcp-task-server", - "purpose": null, - "fileCount": 0, - "lastAccessed": 1774313213043, - "keyFiles": [] - }, - "scripts": { - "path": "scripts", - "purpose": "Build/utility scripts", - "fileCount": 11, - "lastAccessed": 1774313213044, - "keyFiles": [ - "add-modal-ids.py", - "analyze-company-info-layout.js", - "browser-test-admin-switch-button.js", - "browser-test-customer-crud.js", - "browser-test-customer-via-menu.js" - ] - }, - "test-output": { - "path": "test-output", - "purpose": null, - "fileCount": 2, - "lastAccessed": 1774313213044, - "keyFiles": [ - "screen-149-field-type-verification-guide.md", - "unified-field-type-config-panel-test-guide.md" - ] - }, - "test-results": { - "path": "test-results", - "purpose": null, - "fileCount": 1, - "lastAccessed": 1774313213044, - "keyFiles": [] - }, - "ai-assistant/src": { - "path": "ai-assistant/src", - "purpose": "Source code", - "fileCount": 1, - "lastAccessed": 1774313213045, - "keyFiles": [ - "app.js" - ] - }, - "frontend/app": { - "path": "frontend/app", - "purpose": "Application code", - "fileCount": 5, - "lastAccessed": 1774313213046, - "keyFiles": [ - "favicon.ico", - "globals.css", - "layout.tsx" - ] - }, - "frontend/components": { - "path": "frontend/components", - "purpose": "UI components", - "fileCount": 1, - "lastAccessed": 1774313213046, - "keyFiles": [ - "GlobalFileViewer.tsx" - ] - }, - "mcp-agent-orchestrator/src": { - "path": "mcp-agent-orchestrator/src", - "purpose": "Source code", - "fileCount": 1, - "lastAccessed": 1774313213047, - "keyFiles": [ - "index.ts" - ] - }, - "mcp-task-queue/data": { - "path": "mcp-task-queue/data", - "purpose": "Data files", - "fileCount": 2, - "lastAccessed": 1774313213047, - "keyFiles": [ - "knowledge.json", - "tasks.json" - ] - }, - "mcp-task-queue/dist": { - "path": "mcp-task-queue/dist", - "purpose": "Distribution/build output", - "fileCount": 28, - "lastAccessed": 1774313213048, - "keyFiles": [ - "agent-runner.d.ts", - "agent-runner.d.ts.map", - "agent-runner.js" - ] - }, - "mcp-task-queue/node_modules": { - "path": "mcp-task-queue/node_modules", - "purpose": "Dependencies", - "fileCount": 1, - "lastAccessed": 1774313213049, - "keyFiles": [] - }, - "mcp-task-queue/src": { - "path": "mcp-task-queue/src", - "purpose": "Source code", - "fileCount": 7, - "lastAccessed": 1774313213049, - "keyFiles": [ - "agent-runner.ts", - "index.ts", - "knowledge-store.ts" - ] - }, - "mcp-task-server/data": { - "path": "mcp-task-server/data", - "purpose": "Data files", - "fileCount": 0, - "lastAccessed": 1774313213049, - "keyFiles": [] - }, - "mcp-task-server/dist": { - "path": "mcp-task-server/dist", - "purpose": "Distribution/build output", - "fileCount": 6, - "lastAccessed": 1774313213050, - "keyFiles": [ - "index.d.ts", - "index.js", - "taskStore.d.ts" - ] - }, - "mcp-task-server/node_modules": { - "path": "mcp-task-server/node_modules", - "purpose": "Dependencies", - "fileCount": 1, - "lastAccessed": 1774313213050, - "keyFiles": [] - }, - "mcp-task-server/src": { - "path": "mcp-task-server/src", - "purpose": "Source code", - "fileCount": 0, - "lastAccessed": 1774313213052, - "keyFiles": [] - } - }, - "hotPaths": [ - { - "path": "frontend/app/(main)/sales/order/page.tsx", - "accessCount": 19, - "lastAccessed": 1774408850812, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/shipping-plan/page.tsx", - "accessCount": 4, - "lastAccessed": 1774313720455, - "type": "file" - }, - { - "path": "frontend/components/common/DataGrid.tsx", - "accessCount": 4, - "lastAccessed": 1774408732451, - "type": "file" - }, - { - "path": "frontend/components/common/DynamicSearchFilter.tsx", - "accessCount": 3, - "lastAccessed": 1774408732309, - "type": "file" - }, - { - "path": "frontend/app/(main)/production/plan-management/page.tsx", - "accessCount": 2, - "lastAccessed": 1774313461313, - "type": "file" - }, - { - "path": "frontend/app/(main)", - "accessCount": 2, - "lastAccessed": 1774313529384, - "type": "directory" - }, - { - "path": "frontend/lib/api/shipping.ts", - "accessCount": 2, - "lastAccessed": 1774313725308, - "type": "file" - }, - { - "path": ".claude/plans/lively-wishing-yeti.md", - "accessCount": 2, - "lastAccessed": 1774313824670, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/shipping-order/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313447495, - "type": "file" - }, - { - "path": "frontend/app/(main)/sales/claim/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313450420, - "type": "file" - }, - { - "path": "frontend/app/(main)/production/process-info/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313450623, - "type": "file" - }, - { - "path": "frontend/components/common/ExcelUploadModal.tsx", - "accessCount": 1, - "lastAccessed": 1774313454238, - "type": "file" - }, - { - "path": "frontend/app/(main)/master-data/item-info/page.tsx", - "accessCount": 1, - "lastAccessed": 1774313528166, - "type": "file" - }, - { - "path": "frontend/components/common/ShippingPlanModal.tsx", - "accessCount": 1, - "lastAccessed": 1774313925751, - "type": "file" - }, - { - "path": "frontend/components/common/TableSettingsModal.tsx", - "accessCount": 1, - "lastAccessed": 1774409034693, - "type": "file" - } - ], - "userDirectives": [] -} \ No newline at end of file diff --git a/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json b/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json deleted file mode 100644 index 319727ce..00000000 --- a/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "037169c7-72ba-4843-8e9a-417ca1423715", - "ended_at": "2026-03-26T08:24:13.261Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json b/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json deleted file mode 100644 index ec93e466..00000000 --- a/.omc/sessions/591d357c-df9d-4bbc-8dfa-1b98a9184e23.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23", - "ended_at": "2026-03-04T08:10:16.810Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json b/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json deleted file mode 100644 index 2d90700f..00000000 --- a/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "8145031e-d7ea-4aa3-94d7-ddaa69383b8a", - "ended_at": "2026-03-26T09:35:10.082Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json deleted file mode 100644 index 5d45e30d..00000000 --- a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43", - "ended_at": "2026-03-24T01:15:06.127Z", - "reason": "other", - "agents_spawned": 1, - "agents_completed": 1, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json deleted file mode 100644 index 123b9291..00000000 --- a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8", - "ended_at": "2026-03-24T01:15:07.644Z", - "reason": "other", - "agents_spawned": 0, - "agents_completed": 0, - "modes_used": [] -} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json deleted file mode 100644 index 176c69ac..00000000 --- a/.omc/state/idle-notif-cooldown.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lastSentAt": "2026-03-24T02:36:44.477Z" -} \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json deleted file mode 100644 index f23e7222..00000000 --- a/.omc/state/mission-state.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "updatedAt": "2026-03-24T00:51:37.962Z", - "missions": [ - { - "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-24T00:50:40.568Z", - "updatedAt": "2026-03-24T00:51:37.962Z", - "status": "done", - "workerCount": 1, - "taskCounts": { - "total": 1, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 1, - "failed": 0 - }, - "agents": [ - { - "name": "Explore:a9237b1", - "role": "Explore", - "ownership": "a9237b1b6af985371", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-24T00:51:37.962Z" - } - ], - "timeline": [ - { - "id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z", - "at": "2026-03-24T00:50:40.568Z", - "kind": "update", - "agent": "Explore:a9237b1", - "detail": "started Explore:a9237b1", - "sourceKey": "session-start:a9237b1b6af985371" - }, - { - "id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z", - "at": "2026-03-24T00:51:37.962Z", - "kind": "completion", - "agent": "Explore:a9237b1", - "detail": "completed", - "sourceKey": "session-stop:a9237b1b6af985371" - } - ] - } - ] -} \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 24ef7619..8fba4591 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -26,6 +26,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -34,6 +35,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -41,6 +43,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, @@ -2361,6 +2364,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", @@ -3122,6 +3131,12 @@ "node": ">=18.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tediousjs/connection-string": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", @@ -3261,7 +3276,6 @@ "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3664,6 +3678,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -3900,6 +3923,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 +4154,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", @@ -4293,6 +4336,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", @@ -5673,6 +5725,45 @@ "node": ">=0.10.0" } }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", @@ -7215,6 +7306,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 +7424,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 +9181,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 +9366,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 +9804,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 +10072,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 +10209,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 +10409,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 +10956,80 @@ "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/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.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 +11403,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", @@ -11738,6 +12055,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 2217eff6..8154371b 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -40,6 +40,7 @@ "http-proxy-middleware": "^3.0.5", "iconv-lite": "^0.7.0", "imap": "^0.8.19", + "imapflow": "^1.2.18", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "mailparser": "^3.7.5", @@ -48,6 +49,7 @@ "mysql2": "^3.15.0", "node-cron": "^4.2.1", "node-fetch": "^2.7.0", + "node-pop3": "^0.11.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -55,6 +57,7 @@ "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", + "socket.io": "^4.8.3", "uuid": "^13.0.0", "winston": "^3.11.0" }, diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 686dc471..d0532997 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -44,6 +44,8 @@ process.on("SIGTERM", () => { logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작..."); const { stopAiAssistant } = require("./utils/startAiAssistant"); stopAiAssistant(); + const { imapConnectionPool } = require("./services/imapConnectionPool"); + imapConnectionPool.destroyAll(); process.exit(0); }); @@ -52,6 +54,8 @@ process.on("SIGINT", () => { logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작..."); const { stopAiAssistant } = require("./utils/startAiAssistant"); stopAiAssistant(); + const { imapConnectionPool } = require("./services/imapConnectionPool"); + imapConnectionPool.destroyAll(); process.exit(0); }); @@ -131,6 +135,8 @@ 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 messengerRoutes from "./routes/messengerRoutes"; // 메신저 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 @@ -377,6 +383,8 @@ 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/messenger", messengerRoutes); // 메신저 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -404,6 +412,22 @@ const server = app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // Socket.IO initialization + try { + const { Server: SocketIOServer } = await import("socket.io"); + const { initMessengerSocket } = await import("./socket/messengerSocket"); + const { setIo } = await import("./socket/socketManager"); + const io = new SocketIOServer(server, { + cors: { origin: "*", methods: ["GET", "POST"] }, + path: "/socket.io", + }); + setIo(io); + initMessengerSocket(io); + logger.info("💬 Socket.IO messenger initialized"); + } catch (error) { + logger.error("❌ Socket.IO initialization failed:", error); + } + // 비동기 초기화 작업 (에러가 발생해도 서버는 유지) initializeServices().catch(err => { logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err); @@ -419,12 +443,16 @@ async function initializeServices() { runTableHistoryActionMigration, runDtgManagementLogMigration, runApprovalSystemMigration, + runUserMailAccountsMigration, + runMessengerMigration, } = await import("./database/runMigration"); await runDashboardMigration(); await runTableHistoryActionMigration(); await runDtgManagementLogMigration(); await runApprovalSystemMigration(); + await runUserMailAccountsMigration(); + await runMessengerMigration(); } catch (error) { logger.error(`❌ 마이그레이션 실패:`, error); } diff --git a/backend-node/src/config/multerMessengerConfig.ts b/backend-node/src/config/multerMessengerConfig.ts new file mode 100644 index 00000000..5971bf89 --- /dev/null +++ b/backend-node/src/config/multerMessengerConfig.ts @@ -0,0 +1,63 @@ +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; + +// Upload directory +const UPLOAD_DIR = process.env.NODE_ENV === 'production' + ? '/app/uploads/messenger-files' + : path.join(process.cwd(), 'uploads', 'messenger-files'); + +// Create directory if not exists +try { + if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + } +} catch (error) { + console.error('Messenger file upload directory creation failed:', error); +} + +// File storage config +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, UPLOAD_DIR); + }, + filename: (req, file, cb) => { + try { + file.originalname = file.originalname.normalize('NFC'); + const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname); + cb(null, `${uniqueId}${ext}`); + } catch (error) { + console.error('Filename processing error:', error); + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`); + } + }, +}); + +// File filter - block dangerous extensions +const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => { + try { + file.originalname = file.originalname.normalize('NFC'); + } catch (error) { + // ignore normalization failure + } + + const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi']; + const ext = path.extname(file.originalname).toLowerCase(); + + if (dangerousExtensions.includes(ext)) { + cb(new Error(`Security: ${ext} files are not allowed.`)); + return; + } + + cb(null, true); +}; + +export const uploadMessengerFile = multer({ + storage, + fileFilter, + limits: { + fileSize: 20 * 1024 * 1024, // 20MB + files: 10, + }, +}); diff --git a/backend-node/src/controllers/messengerController.ts b/backend-node/src/controllers/messengerController.ts new file mode 100644 index 00000000..5db22901 --- /dev/null +++ b/backend-node/src/controllers/messengerController.ts @@ -0,0 +1,220 @@ +import { Request, Response } from 'express'; +import { messengerService } from '../services/messengerService'; +import { AuthenticatedRequest } from '../types/auth'; +import { getIo } from '../socket/socketManager'; +import path from 'path'; + +class MessengerController { + async getRooms(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const rooms = await messengerService.getRooms(user.userId, user.companyCode!); + res.json({ success: true, data: rooms }); + } catch (error) { + const err = error as Error; + console.error('getRooms error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async createRoom(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const room_type = req.body.room_type ?? req.body.type; + const room_name = req.body.room_name ?? req.body.name; + const participant_ids = req.body.participant_ids ?? req.body.participantIds; + + if (!room_type || !participant_ids || !Array.isArray(participant_ids)) { + return res.status(400).json({ success: false, message: 'room_type and participant_ids are required.' }); + } + + const room = await messengerService.createRoom(user.userId, user.companyCode!, { + room_type, + room_name, + participant_ids, + }); + res.json({ success: true, data: room }); + } catch (error) { + const err = error as Error; + console.error('createRoom error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getMessages(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const limit = parseInt(req.query.limit as string, 10) || 50; + const before = req.query.before ? parseInt(req.query.before as string, 10) : undefined; + + const messages = await messengerService.getMessages(roomId, user.userId, user.companyCode!, limit, before); + res.json({ success: true, data: messages }); + } catch (error) { + const err = error as Error; + console.error('getMessages error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async sendMessage(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const content = req.body.content; + const messageType = req.body.type ?? req.body.message_type ?? 'text'; + const parentId = req.body.parentId ?? req.body.parent_message_id ?? null; + + if (!content) { + return res.status(400).json({ success: false, message: 'content is required.' }); + } + + const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId); + + // Broadcast to all room participants via Socket.IO + const io = getIo(); + if (io) { + io.to(`${user.companyCode}:${roomId}`).emit('new_message', message); + } + + res.json({ success: true, data: message }); + } catch (error) { + const err = error as Error; + console.error('sendMessage error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async markAsRead(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + await messengerService.markAsRead(roomId, user.userId); + res.json({ success: true }); + } catch (error) { + const err = error as Error; + console.error('markAsRead error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async uploadFile(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const files = req.files as Express.Multer.File[]; + + if (!files || files.length === 0) { + return res.status(400).json({ success: false, message: 'No files uploaded.' }); + } + + const roomId = parseInt(req.body.room_id, 10); + if (!roomId) { + return res.status(400).json({ success: false, message: 'room_id is required.' }); + } + + const io = getIo(); + const savedFiles = []; + for (const file of files) { + // Use a readable placeholder as content to avoid filename encoding issues + const isImage = file.mimetype.startsWith('image/'); + const content = isImage ? '[이미지]' : '[파일]'; + + // Create a file message + const message = await messengerService.sendMessage( + roomId, + user.userId, + user.companyCode!, + content, + 'file' + ); + + const savedFile = await messengerService.saveFile(message.id, { + originalName: file.originalname, + storedName: file.filename, + filePath: file.path, + fileSize: file.size, + mimeType: file.mimetype, + }); + + message.files = [savedFile]; + + // Broadcast to room so recipients receive it in real-time + io.to(`${user.companyCode}:${roomId}`).emit('new_message', message); + + savedFiles.push({ message, file: savedFile }); + } + + res.json({ success: true, data: savedFiles }); + } catch (error) { + const err = error as Error; + console.error('uploadFile error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async downloadFile(req: Request, res: Response) { + try { + const fileId = parseInt(req.params.fileId, 10); + const file = await messengerService.getFileById(fileId); + + if (!file) { + return res.status(404).json({ success: false, message: 'File not found.' }); + } + + res.download(file.file_path, file.original_name); + } catch (error) { + const err = error as Error; + console.error('downloadFile error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getCompanyUsers(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const users = await messengerService.getCompanyUsers(user.companyCode!, user.userId); + res.json({ success: true, data: users }); + } catch (error) { + const err = error as Error; + console.error('getCompanyUsers error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async updateRoom(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const roomId = parseInt(req.params.roomId, 10); + const { room_name } = req.body; + + if (!room_name) { + return res.status(400).json({ success: false, message: 'room_name is required.' }); + } + + const room = await messengerService.updateRoom(roomId, user.companyCode!, room_name); + if (!room) { + return res.status(404).json({ success: false, message: 'Room not found.' }); + } + + res.json({ success: true, data: room }); + } catch (error) { + const err = error as Error; + console.error('updateRoom error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } + + async getUnreadCount(req: Request, res: Response) { + try { + const user = (req as AuthenticatedRequest).user!; + const count = await messengerService.getUnreadCount(user.userId, user.companyCode!); + res.json({ success: true, data: { unread_count: count } }); + } catch (error) { + const err = error as Error; + console.error('getUnreadCount error:', err.message); + res.status(500).json({ success: false, message: err.message }); + } + } +} + +export const messengerController = new MessengerController(); diff --git a/backend-node/src/controllers/userMailController.ts b/backend-node/src/controllers/userMailController.ts new file mode 100644 index 00000000..65eb3c22 --- /dev/null +++ b/backend-node/src/controllers/userMailController.ts @@ -0,0 +1,358 @@ +import { Response } from 'express'; +import { AuthenticatedRequest } from '../types/auth'; +import { userMailAccountService } from '../services/userMailAccountService'; +import { userMailImapService } from '../services/userMailImapService'; +import { userMailSmtpService } from '../services/userMailSmtpService'; +import { encryptionService } from '../services/encryptionService'; +import { imapConnectionPool } from '../services/imapConnectionPool'; +import { mailCache } from '../services/mailCache'; + +class UserMailController { + async listAccounts(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const protocol = req.query.protocol as string | undefined; + const accounts = await userMailAccountService.getAccountsByUserId(userId, protocol); + // 비밀번호 제거 후 반환 + const safe = accounts.map(({ password, ...rest }) => rest); + res.json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async createAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + // 저장 전 연결 테스트 (임시 account 객체 사용, 평문 비밀번호를 암호화해서 전달) + const tempAccount = { ...req.body, id: 0, userId, status: 'active', password: encryptionService.encrypt(req.body.password) }; + const service = userMailImapService; + const testResult = await service.testConnection(tempAccount); + if (!testResult.success) { + return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` }); + } + + const account = await userMailAccountService.createAccount(userId, req.body); + const { password, ...safe } = account; + res.status(201).json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async updateAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + + // 비밀번호 변경이 포함된 경우 연결 테스트 + if (req.body.password) { + const existing = await userMailAccountService.getAccountById(accountId, userId); + if (!existing) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const tempAccount = { ...existing, ...req.body, password: encryptionService.encrypt(req.body.password) }; + const service = userMailImapService; + const testResult = await service.testConnection(tempAccount); + if (!testResult.success) { + return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` }); + } + } + + const account = await userMailAccountService.updateAccount(accountId, userId, req.body); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + imapConnectionPool.destroyByAccount(accountId); + mailCache.invalidateByPrefix(`mailList:${accountId}:`); + mailCache.invalidateByPrefix(`mailDetail:${accountId}:`); + + const { password, ...safe } = account; + res.json({ success: true, data: safe }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async deleteAccount(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const deleted = await userMailAccountService.deleteAccount(accountId, userId); + if (!deleted) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + imapConnectionPool.destroyByAccount(accountId); + mailCache.invalidateByPrefix(`mailList:${accountId}:`); + mailCache.invalidateByPrefix(`mailDetail:${accountId}:`); + + res.json({ success: true, message: '계정이 삭제되었습니다.' }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async testConnectionDirect(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const { protocol, host, port, useTls, username, password } = req.body; + if (!protocol || !host || !port || !username || !password) { + return res.status(400).json({ success: false, message: '필수 항목 누락' }); + } + + const tempAccount = { + id: 0, userId, displayName: '', email: '', protocol, host, port, + useTls: useTls ?? true, username, status: 'active', + password: encryptionService.encrypt(password), + createdAt: new Date(), updatedAt: new Date(), + }; + + const service = userMailImapService; + const result = await service.testConnection(tempAccount); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async testConnection(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const service = userMailImapService; + const result = await service.testConnection(account); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async listMails(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const limit = parseInt(req.query.limit as string) || 50; + const service = userMailImapService; + const mails = await service.fetchMailList(account, limit); + res.json({ success: true, data: mails }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async streamMails(req: AuthenticatedRequest, res: Response) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const userId = (req as any).user?.userId; + const accountId = parseInt(req.params.accountId); + const limit = parseInt(req.query.limit as string) || 20; + const before = req.query.before ? parseInt(req.query.before as string) : null; + + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) { + res.write(`event: error\ndata: ${JSON.stringify({ message: '계정을 찾을 수 없습니다.' })}\n\n`); + return res.end(); + } + + let ended = false; + req.on('close', () => { ended = true; }); + + await userMailImapService.fetchMailListStream( + account, limit, before, + (mail) => { + if (!ended) res.write(`data: ${JSON.stringify(mail)}\n\n`); + }, + () => { + if (!ended) { + res.write(`event: done\ndata: {}\n\n`); + res.end(); + } + }, + (err) => { + if (!ended) { + res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); + res.end(); + } + } + ); + } + + async getMailDetail(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const detail = await service.getMailDetail(account, seqno); + if (!detail) return res.status(404).json({ success: false, message: '메일을 찾을 수 없습니다.' }); + + res.json({ success: true, data: detail }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async markAsRead(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const result = await service.markAsRead(account, seqno); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async deleteMail(req: AuthenticatedRequest, res: Response) { + try { + const userId = (req as any).user?.userId; + if (!userId) return res.status(401).json({ success: false, message: '인증 필요' }); + + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, userId); + if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' }); + + const seqno = parseInt(req.params.seqno); + const service = userMailImapService; + const result = await service.deleteMail(account, seqno); + res.json({ success: true, data: result }); + } catch (error) { + res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' }); + } + } + + async listFolders(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folders = await userMailImapService.listFolders(account); + res.json({ success: true, data: folders }); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async streamFolderMails(req: AuthenticatedRequest, res: Response): Promise { + const accountId = parseInt(req.params.accountId); + const folder = decodeURIComponent(req.params.folder); + const limit = parseInt(req.query.limit as string) || 20; + const before = req.query.before ? parseInt(req.query.before as string) : null; + + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + await userMailImapService.streamMailsByFolder( + account, folder, limit, before, + (mail) => { + res.write(`event: message\ndata: ${JSON.stringify(mail)}\n\n`); + }, + () => { + res.write(`event: done\ndata: {}\n\n`); + res.end(); + }, + (err) => { + res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); + res.end(); + } + ); + } + + async moveMail(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const { targetFolder } = req.body; + if (!targetFolder) { res.status(400).json({ success: false, message: 'targetFolder 필요' }); return; } + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const result = await userMailImapService.moveMail(account, seqno, targetFolder); + res.json(result); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async getAttachments(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folder = (req.query.folder as string) || 'INBOX'; + const attachments = await userMailImapService.getAttachmentList(account, seqno, folder); + res.json({ success: true, data: attachments }); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + + async downloadAttachment(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const seqno = parseInt(req.params.seqno); + const partId = decodeURIComponent(req.params.partId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const folder = (req.query.folder as string) || 'INBOX'; + const filenameHint = (req.params.filename as string | undefined) || (req.query.filename as string | undefined); + await userMailImapService.downloadAttachment(account, seqno, partId, res, folder, filenameHint); + } catch (err) { + if (!res.headersSent) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } + } + + async sendMail(req: AuthenticatedRequest, res: Response): Promise { + try { + const accountId = parseInt(req.params.accountId); + const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId); + if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; } + const result = await userMailSmtpService.sendMail(account, req.body); + res.json(result); + } catch (err) { + res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' }); + } + } +} + +export const userMailController = new UserMailController(); diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts index 07f714d6..73523b92 100644 --- a/backend-node/src/database/runMigration.ts +++ b/backend-node/src/database/runMigration.ts @@ -112,6 +112,64 @@ 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); + } +} + +/** + * Messenger tables migration + */ +export async function runMessengerMigration() { + try { + console.log("🔄 메신저 테이블 마이그레이션 시작..."); + + const sqlFilePath = path.join( + __dirname, + "../../../db/migrations/messenger_tables.sql" + ); + + if (!fs.existsSync(sqlFilePath)) { + console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath); + return; + } + + const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); + await PostgreSQLService.query(sqlContent); + + console.log("✅ 메신저 테이블 마이그레이션 완료!"); + } catch (error) { + console.error("❌ 메신저 테이블 마이그레이션 실패:", error); + if (error instanceof Error && error.message.includes("already exists")) { + console.log("ℹ️ 테이블이 이미 존재합니다."); + } + } +} + export async function runDtgManagementLogMigration() { try { console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 8dfe28b3..b967f32e 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -29,9 +29,9 @@ export const authenticateToken = async ( next: NextFunction ): Promise => { try { - // Authorization 헤더에서 토큰 추출 + // Authorization 헤더 또는 query param에서 토큰 추출 (파일 다운로드용) const authHeader = req.get("Authorization"); - const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN + const token = (authHeader && authHeader.split(" ")[1]) || (req.query.token as string) || null; if (!token) { res.status(401).json({ diff --git a/backend-node/src/routes/messengerRoutes.ts b/backend-node/src/routes/messengerRoutes.ts new file mode 100644 index 00000000..a1b08e0a --- /dev/null +++ b/backend-node/src/routes/messengerRoutes.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { messengerController } from '../controllers/messengerController'; +import { authenticateToken } from '../middleware/authMiddleware'; +import { uploadMessengerFile } from '../config/multerMessengerConfig'; + +const router = Router(); + +// All messenger routes require authentication +router.use(authenticateToken); + +// GET /api/messenger/rooms - Get my rooms +router.get('/rooms', (req, res) => messengerController.getRooms(req, res)); + +// POST /api/messenger/rooms - Create a room +router.post('/rooms', (req, res) => messengerController.createRoom(req, res)); + +// GET /api/messenger/rooms/:roomId/messages - Get messages +router.get('/rooms/:roomId/messages', (req, res) => messengerController.getMessages(req, res)); + +// POST /api/messenger/rooms/:roomId/messages - Send message +router.post('/rooms/:roomId/messages', (req, res) => messengerController.sendMessage(req, res)); + +// POST /api/messenger/rooms/:roomId/read - Mark as read +router.post('/rooms/:roomId/read', (req, res) => messengerController.markAsRead(req, res)); + +// PUT /api/messenger/rooms/:roomId - Update room +router.put('/rooms/:roomId', (req, res) => messengerController.updateRoom(req, res)); + +// POST /api/messenger/files/upload - Upload files +router.post( + '/files/upload', + uploadMessengerFile.array('files', 10), + (req, res) => messengerController.uploadFile(req, res) +); + +// GET /api/messenger/files/:fileId - Download file +router.get('/files/:fileId', (req, res) => messengerController.downloadFile(req, res)); + +// GET /api/messenger/users - Get company users +router.get('/users', (req, res) => messengerController.getCompanyUsers(req, res)); + +// GET /api/messenger/unread - Get unread count +router.get('/unread', (req, res) => messengerController.getUnreadCount(req, res)); + +export default router; diff --git a/backend-node/src/routes/userMailRoutes.ts b/backend-node/src/routes/userMailRoutes.ts new file mode 100644 index 00000000..d1c1a3db --- /dev/null +++ b/backend-node/src/routes/userMailRoutes.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import { authenticateToken } from '../middleware/authMiddleware'; +import { userMailController } from '../controllers/userMailController'; + +const router = express.Router(); +router.use(authenticateToken); + +router.post('/test-connection', (req, res) => userMailController.testConnectionDirect(req as any, res)); +router.get('/accounts', (req, res) => userMailController.listAccounts(req as any, res)); +router.post('/accounts', (req, res) => userMailController.createAccount(req as any, res)); +router.put('/accounts/:accountId', (req, res) => userMailController.updateAccount(req as any, res)); +router.delete('/accounts/:accountId', (req, res) => userMailController.deleteAccount(req as any, res)); +router.post('/accounts/:accountId/test', (req, res) => userMailController.testConnection(req as any, res)); +router.get('/accounts/:accountId/mails/stream', (req, res) => userMailController.streamMails(req as any, res)); +router.get('/accounts/:accountId/mails', (req, res) => userMailController.listMails(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.getMailDetail(req as any, res)); +router.post('/accounts/:accountId/mails/:seqno/mark-read', (req, res) => userMailController.markAsRead(req as any, res)); +router.delete('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.deleteMail(req as any, res)); +router.get('/accounts/:accountId/folders', (req, res) => userMailController.listFolders(req as any, res)); +router.get('/accounts/:accountId/folders/:folder/mails/stream', (req, res) => userMailController.streamFolderMails(req as any, res)); +router.post('/accounts/:accountId/mails/:seqno/move', (req, res) => userMailController.moveMail(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno/attachments', (req, res) => userMailController.getAttachments(req as any, res)); +router.get('/accounts/:accountId/mails/:seqno/attachment/:partId', (req, res) => userMailController.downloadAttachment(req as any, res)); +router.post('/accounts/:accountId/send', (req, res) => userMailController.sendMail(req as any, res)); + +export default router; diff --git a/backend-node/src/services/encryptionService.ts b/backend-node/src/services/encryptionService.ts index a3608b2e..c21f009c 100644 --- a/backend-node/src/services/encryptionService.ts +++ b/backend-node/src/services/encryptionService.ts @@ -14,7 +14,7 @@ class EncryptionService { encrypt(text: string): string { const iv = crypto.randomBytes(16); - const cipher = crypto.createCipher(this.algorithm, this.key); + const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); @@ -34,7 +34,7 @@ class EncryptionService { const iv = Buffer.from(ivHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex'); - const decipher = crypto.createDecipher(this.algorithm, this.key); + const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); diff --git a/backend-node/src/services/imapConnectionPool.ts b/backend-node/src/services/imapConnectionPool.ts new file mode 100644 index 00000000..04233b5e --- /dev/null +++ b/backend-node/src/services/imapConnectionPool.ts @@ -0,0 +1,109 @@ +import { ImapFlow } from 'imapflow'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; + +interface PoolEntry { + client: ImapFlow; + accountId: number; + lastUsed: number; + busy: boolean; + queue: Array<{ fn: (client: ImapFlow) => Promise; resolve: (v: any) => void; reject: (e: any) => void }>; +} + +class ImapConnectionPool { + private pool = new Map(); + private readonly maxIdleMs = 300_000; + + constructor() { + setInterval(() => this.cleanupIdle(), 60_000); + process.on('SIGTERM', () => this.destroyAll()); + process.on('SIGINT', () => this.destroyAll()); + } + + async execute(account: UserMailAccount, fn: (client: ImapFlow) => Promise): Promise { + const decryptedPassword = encryptionService.decrypt(account.password); + let entry = this.pool.get(account.id); + + if (entry && !entry.client.usable) { + this.pool.delete(account.id); + entry = undefined; + } + + if (!entry) { + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: account.useTls, + auth: { user: account.username, pass: decryptedPassword }, + logger: false as any, + tls: { rejectUnauthorized: false }, + }); + await client.connect(); + entry = { client, accountId: account.id, lastUsed: Date.now(), busy: false, queue: [] }; + this.pool.set(account.id, entry); + + client.on('close', () => { + const e = this.pool.get(account.id); + if (e && e.client === client) { + this.pool.delete(account.id); + for (const pending of e.queue) pending.reject(new Error('IMAP 연결이 끊겼습니다')); + e.queue = []; + } + }); + } + + if (entry.busy) { + return new Promise((resolve, reject) => { + entry!.queue.push({ fn: fn as any, resolve, reject }); + }); + } + + return this.runWithEntry(entry, fn); + } + + private async runWithEntry(entry: PoolEntry, fn: (client: ImapFlow) => Promise): Promise { + entry.busy = true; + entry.lastUsed = Date.now(); + try { + return await fn(entry.client); + } catch (err) { + if (!entry.client.usable) { + this.pool.delete(entry.accountId); + } + throw err; + } finally { + entry.busy = false; + if (entry.queue.length > 0) { + const next = entry.queue.shift()!; + this.runWithEntry(entry, next.fn).then(next.resolve).catch(next.reject); + } + } + } + + private cleanupIdle() { + const now = Date.now(); + for (const [id, entry] of this.pool.entries()) { + if (!entry.busy && entry.queue.length === 0 && now - entry.lastUsed > this.maxIdleMs) { + try { entry.client.logout(); } catch {} + this.pool.delete(id); + } + } + } + + destroyByAccount(accountId: number) { + const entry = this.pool.get(accountId); + if (entry) { + try { entry.client.logout(); } catch {} + this.pool.delete(accountId); + } + } + + destroyAll() { + for (const entry of this.pool.values()) { + try { entry.client.logout(); } catch {} + } + this.pool.clear(); + } +} + +export const imapConnectionPool = new ImapConnectionPool(); diff --git a/backend-node/src/services/mailCache.ts b/backend-node/src/services/mailCache.ts new file mode 100644 index 00000000..b410280a --- /dev/null +++ b/backend-node/src/services/mailCache.ts @@ -0,0 +1,43 @@ +interface CacheEntry { + data: T; + expiresAt: number; +} + +class MailCache { + private cache = new Map>(); + private readonly maxEntries = 1000; + + constructor() { + setInterval(() => this.sweep(), 60_000); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + return entry.data as T; + } + + set(key: string, data: T, ttlMs: number) { + if (this.cache.size >= this.maxEntries) this.sweep(); + this.cache.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + invalidateByPrefix(prefix: string) { + for (const key of this.cache.keys()) { + if (key.startsWith(prefix)) this.cache.delete(key); + } + } + + private sweep() { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) this.cache.delete(key); + } + } +} + +export const mailCache = new MailCache(); diff --git a/backend-node/src/services/messengerService.ts b/backend-node/src/services/messengerService.ts new file mode 100644 index 00000000..36da6678 --- /dev/null +++ b/backend-node/src/services/messengerService.ts @@ -0,0 +1,405 @@ +import { PostgreSQLService } from '../database/PostgreSQLService'; +import { + MessengerRoom, + MessengerMessage, + MessengerFile, + MessengerUser, + CreateRoomRequest, + MessengerParticipant, +} from '../types/messenger'; + +class MessengerService { + /** + * Get rooms for a user with last message and unread count + */ + async getRooms(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT r.*, + m.content AS last_message, + m.created_at AS last_message_at, + m.sender_id AS last_sender_id, + COALESCE(unread.cnt, 0)::int AS unread_count + FROM messenger_rooms r + INNER JOIN messenger_participants p ON p.room_id = r.id AND p.user_id = $1 + LEFT JOIN LATERAL ( + SELECT content, created_at, sender_id + FROM messenger_messages + WHERE room_id = r.id + ORDER BY created_at DESC + LIMIT 1 + ) m ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS cnt + FROM messenger_messages + WHERE room_id = r.id + AND created_at > p.last_read_at + AND sender_id != $1 + ) unread ON true + WHERE r.company_code = $2 + ORDER BY COALESCE(m.created_at, r.created_at) DESC`, + [userId, companyCode] + ); + + // Attach participants to each room + const rooms: MessengerRoom[] = result.rows; + if (rooms.length > 0) { + const roomIds = rooms.map((r) => r.id); + const partResult = await PostgreSQLService.query( + `SELECT mp.*, ui.user_name, ui.dept_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo + FROM messenger_participants mp + LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code + WHERE mp.room_id = ANY($1)`, + [roomIds] + ); + const partMap = new Map(); + for (const p of partResult.rows) { + if (!partMap.has(p.room_id)) partMap.set(p.room_id, []); + partMap.get(p.room_id)!.push(p); + } + for (const room of rooms) { + room.participants = partMap.get(room.id) || []; + } + } + + return rooms; + } + + /** + * Create a room. For DM, return existing room if one already exists between the two users. + */ + async createRoom( + creatorId: string, + companyCode: string, + data: CreateRoomRequest + ): Promise { + // DM duplicate check + if (data.room_type === 'dm' && data.participant_ids.length === 1) { + const otherUserId = data.participant_ids[0]; + const existing = await PostgreSQLService.query( + `SELECT r.* FROM messenger_rooms r + WHERE r.company_code = $1 AND r.room_type = 'dm' + AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $2) + AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $3) + AND (SELECT COUNT(*) FROM messenger_participants WHERE room_id = r.id) = 2 + LIMIT 1`, + [companyCode, creatorId, otherUserId] + ); + if (existing.rows.length > 0) { + return existing.rows[0]; + } + } + + // Create room + const roomResult = await PostgreSQLService.query( + `INSERT INTO messenger_rooms (company_code, room_type, room_name, created_by) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [companyCode, data.room_type, data.room_name || null, creatorId] + ); + const room: MessengerRoom = roomResult.rows[0]; + + // Add participants (creator + others) + const allParticipants = [creatorId, ...data.participant_ids.filter((id) => id !== creatorId)]; + for (const uid of allParticipants) { + await PostgreSQLService.query( + `INSERT INTO messenger_participants (room_id, user_id, company_code) + VALUES ($1, $2, $3) + ON CONFLICT (room_id, user_id) DO NOTHING`, + [room.id, uid, companyCode] + ); + } + + return room; + } + + /** + * Get messages with cursor-based pagination + */ + async getMessages( + roomId: number, + userId: string, + companyCode: string, + limit: number = 50, + before?: number + ): Promise { + let query: string; + let params: any[]; + + if (before) { + query = `SELECT msg.*, + ui.user_name AS sender_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo, + COALESCE(tc.thread_count, 0)::int AS thread_count + FROM messenger_messages msg + LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS thread_count + FROM messenger_messages + WHERE parent_message_id = msg.id + ) tc ON true + WHERE msg.room_id = $1 AND msg.company_code = $2 AND msg.id < $3 + ORDER BY msg.created_at DESC + LIMIT $4`; + params = [roomId, companyCode, before, limit]; + } else { + query = `SELECT msg.*, + ui.user_name AS sender_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo, + COALESCE(tc.thread_count, 0)::int AS thread_count + FROM messenger_messages msg + LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS thread_count + FROM messenger_messages + WHERE parent_message_id = msg.id + ) tc ON true + WHERE msg.room_id = $1 AND msg.company_code = $2 + ORDER BY msg.created_at DESC + LIMIT $3`; + params = [roomId, companyCode, limit]; + } + + const result = await PostgreSQLService.query(query, params); + // Reverse so messages are in chronological order (query uses DESC for cursor pagination) + const messages: MessengerMessage[] = result.rows.reverse(); + + // Attach reactions and files + if (messages.length > 0) { + const msgIds = messages.map((m) => m.id); + + const [reactionsResult, filesResult] = await Promise.all([ + PostgreSQLService.query( + `SELECT * FROM messenger_reactions WHERE message_id = ANY($1)`, + [msgIds] + ), + PostgreSQLService.query( + `SELECT * FROM messenger_files WHERE message_id = ANY($1)`, + [msgIds] + ), + ]); + + const reactionsMap = new Map(); + for (const r of reactionsResult.rows) { + if (!reactionsMap.has(r.message_id)) reactionsMap.set(r.message_id, []); + reactionsMap.get(r.message_id)!.push(r); + } + + const filesMap = new Map(); + for (const f of filesResult.rows) { + if (!filesMap.has(f.message_id)) filesMap.set(f.message_id, []); + filesMap.get(f.message_id)!.push(f); + } + + for (const msg of messages) { + msg.reactions = reactionsMap.get(msg.id) || []; + msg.files = filesMap.get(msg.id) || []; + } + } + + return messages; + } + + /** + * Send a message and return the saved message + */ + async sendMessage( + roomId: number, + senderId: string, + companyCode: string, + content: string, + messageType: string = 'text', + parentMessageId?: number + ): Promise { + const result = await PostgreSQLService.query( + `INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type, parent_message_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [roomId, senderId, companyCode, content, messageType, parentMessageId || null] + ); + + // Update room's updated_at + await PostgreSQLService.query( + `UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`, + [roomId] + ); + + // Get sender info + const userResult = await PostgreSQLService.query( + `SELECT user_name, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info WHERE user_id = $1 AND company_code = $2`, + [senderId, companyCode] + ); + + const message = result.rows[0]; + if (userResult.rows.length > 0) { + message.sender_name = userResult.rows[0].user_name; + message.sender_photo = userResult.rows[0].photo; + } + message.reactions = []; + message.files = []; + + return message; + } + + /** + * Mark messages as read + */ + async markAsRead(roomId: number, userId: string): Promise { + await PostgreSQLService.query( + `UPDATE messenger_participants SET last_read_at = NOW() + WHERE room_id = $1 AND user_id = $2`, + [roomId, userId] + ); + } + + /** + * Get company users for user picker + */ + async getCompanyUsers(companyCode: string, excludeUserId?: string): Promise { + let query: string; + let params: any[]; + + if (excludeUserId) { + query = `SELECT user_id, user_name, dept_name, email, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info + WHERE company_code = $1 AND user_id != $2 + ORDER BY user_name`; + params = [companyCode, excludeUserId]; + } else { + query = `SELECT user_id, user_name, dept_name, email, + CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo + FROM user_info + WHERE company_code = $1 + ORDER BY user_name`; + params = [companyCode]; + } + + const result = await PostgreSQLService.query(query, params); + return result.rows; + } + + /** + * Add a reaction to a message + */ + async addReaction(messageId: number, userId: string, emoji: string): Promise { + await PostgreSQLService.query( + `INSERT INTO messenger_reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id, emoji) DO NOTHING`, + [messageId, userId, emoji] + ); + } + + /** + * Remove a reaction from a message + */ + async removeReaction(messageId: number, userId: string, emoji: string): Promise { + await PostgreSQLService.query( + `DELETE FROM messenger_reactions + WHERE message_id = $1 AND user_id = $2 AND emoji = $3`, + [messageId, userId, emoji] + ); + } + + /** + * Get total unread message count for badge + */ + async getUnreadCount(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT COALESCE(SUM(cnt), 0)::int AS total_unread + FROM ( + SELECT COUNT(*) AS cnt + FROM messenger_participants p + INNER JOIN messenger_messages m ON m.room_id = p.room_id + AND m.created_at > p.last_read_at + AND m.sender_id != $1 + WHERE p.user_id = $1 AND p.company_code = $2 + GROUP BY p.room_id + ) sub`, + [userId, companyCode] + ); + return result.rows[0]?.total_unread || 0; + } + + /** + * Save file info for a message + */ + async saveFile( + messageId: number, + fileInfo: { originalName: string; storedName: string; filePath: string; fileSize: number; mimeType: string } + ): Promise { + const result = await PostgreSQLService.query( + `INSERT INTO messenger_files (message_id, original_name, stored_name, file_path, file_size, mime_type) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [messageId, fileInfo.originalName, fileInfo.storedName, fileInfo.filePath, fileInfo.fileSize, fileInfo.mimeType] + ); + return result.rows[0]; + } + + /** + * Get room by ID with participants + */ + async getRoomById(roomId: number, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT * FROM messenger_rooms WHERE id = $1 AND company_code = $2`, + [roomId, companyCode] + ); + if (result.rows.length === 0) return null; + + const room: MessengerRoom = result.rows[0]; + + const partResult = await PostgreSQLService.query( + `SELECT mp.*, ui.user_name, ui.dept_name, + CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo + FROM messenger_participants mp + LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code + WHERE mp.room_id = $1`, + [roomId] + ); + room.participants = partResult.rows; + + return room; + } + + /** + * Update room name + */ + async updateRoom(roomId: number, companyCode: string, roomName: string): Promise { + const result = await PostgreSQLService.query( + `UPDATE messenger_rooms SET room_name = $1, updated_at = NOW() + WHERE id = $2 AND company_code = $3 + RETURNING *`, + [roomName, roomId, companyCode] + ); + return result.rows.length > 0 ? result.rows[0] : null; + } + + /** + * Get file by ID + */ + async getFileById(fileId: number): Promise { + const result = await PostgreSQLService.query( + `SELECT * FROM messenger_files WHERE id = $1`, + [fileId] + ); + return result.rows.length > 0 ? result.rows[0] : null; + } + + /** + * Get participant room IDs for socket join + */ + async getUserRoomIds(userId: string, companyCode: string): Promise { + const result = await PostgreSQLService.query( + `SELECT room_id FROM messenger_participants + WHERE user_id = $1 AND company_code = $2`, + [userId, companyCode] + ); + return result.rows.map((r: any) => r.room_id); + } +} + +export const messengerService = new MessengerService(); diff --git a/backend-node/src/services/userMailAccountService.ts b/backend-node/src/services/userMailAccountService.ts new file mode 100644 index 00000000..129fd0f2 --- /dev/null +++ b/backend-node/src/services/userMailAccountService.ts @@ -0,0 +1,121 @@ +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { encryptionService } from "./encryptionService"; + +export interface UserMailAccount { + id: number; + userId: string; + displayName: string; + email: string; + protocol: 'imap'; + host: string; + port: number; + useTls: boolean; + username: string; + password: string; // 암호화된 상태 + status: string; + createdAt: string | Date; + updatedAt: string | Date; +} + +export interface CreateUserMailAccountDto { + displayName: string; + email: string; + protocol: 'imap'; + host: string; + port: number; + useTls: boolean; + username: string; + password: string; // 평문 (서비스에서 암호화) +} + +function rowToAccount(row: any): UserMailAccount { + return { + id: row.id, + userId: row.user_id, + displayName: row.display_name, + email: row.email, + protocol: row.protocol, + host: row.host, + port: row.port, + useTls: row.use_tls, + username: row.username, + password: row.password, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +class UserMailAccountService { + async getAccountsByUserId(userId: string, protocol?: string): Promise { + let query = 'SELECT * FROM user_mail_accounts WHERE user_id = $1'; + const params: any[] = [userId]; + + if (protocol) { + query += ' AND protocol = $2'; + params.push(protocol); + } + + query += ' ORDER BY created_at DESC'; + const result = await PostgreSQLService.query(query, params); + return result.rows.map(rowToAccount); + } + + async getAccountById(id: number, userId: string): Promise { + const result = await PostgreSQLService.query( + 'SELECT * FROM user_mail_accounts WHERE id = $1 AND user_id = $2', + [id, userId] + ); + return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null; + } + + async createAccount(userId: string, dto: CreateUserMailAccountDto): Promise { + const encryptedPassword = encryptionService.encrypt(dto.password); + const result = await PostgreSQLService.query( + `INSERT INTO user_mail_accounts (user_id, display_name, email, protocol, host, port, use_tls, username, password) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [userId, dto.displayName, dto.email, dto.protocol, dto.host, dto.port, dto.useTls, dto.username, encryptedPassword] + ); + return rowToAccount(result.rows[0]); + } + + async updateAccount(id: number, userId: string, dto: Partial): Promise { + const existing = await this.getAccountById(id, userId); + if (!existing) return null; + + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.displayName !== undefined) { fields.push(`display_name = $${paramIndex++}`); values.push(dto.displayName); } + if (dto.email !== undefined) { fields.push(`email = $${paramIndex++}`); values.push(dto.email); } + if (dto.protocol !== undefined) { fields.push(`protocol = $${paramIndex++}`); values.push(dto.protocol); } + if (dto.host !== undefined) { fields.push(`host = $${paramIndex++}`); values.push(dto.host); } + if (dto.port !== undefined) { fields.push(`port = $${paramIndex++}`); values.push(dto.port); } + if (dto.useTls !== undefined) { fields.push(`use_tls = $${paramIndex++}`); values.push(dto.useTls); } + if (dto.username !== undefined) { fields.push(`username = $${paramIndex++}`); values.push(dto.username); } + if (dto.password !== undefined) { fields.push(`password = $${paramIndex++}`); values.push(encryptionService.encrypt(dto.password)); } + + if (fields.length === 0) return existing; + + fields.push(`updated_at = NOW()`); + values.push(id, userId); + + const result = await PostgreSQLService.query( + `UPDATE user_mail_accounts SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`, + values + ); + return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null; + } + + async deleteAccount(id: number, userId: string): Promise { + const result = await PostgreSQLService.query( + 'DELETE FROM user_mail_accounts WHERE id = $1 AND user_id = $2', + [id, userId] + ); + return result.rowCount > 0; + } +} + +export const userMailAccountService = new UserMailAccountService(); diff --git a/backend-node/src/services/userMailImapService.ts b/backend-node/src/services/userMailImapService.ts new file mode 100644 index 00000000..c135aa2c --- /dev/null +++ b/backend-node/src/services/userMailImapService.ts @@ -0,0 +1,419 @@ +import { ImapFlow } from 'imapflow'; +import { simpleParser } from 'mailparser'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; +import { imapConnectionPool } from './imapConnectionPool'; +import { mailCache } from './mailCache'; + +export interface ReceivedMail { + id: string; + messageId: string; + from: string; + to: string; + subject: string; + date: Date; + preview: string; + isRead: boolean; + hasAttachments: boolean; +} + +export interface MailDetail extends ReceivedMail { + htmlBody: string; + textBody: string; + cc?: string; + bcc?: string; + attachments: Array<{ + filename: string; + contentType: string; + size: number; + }>; +} + +class UserMailImapService { + async fetchMailList(account: UserMailAccount, limit: number = 50): Promise { + const cacheKey = `mailList:${account.id}:INBOX:${limit}`; + const cached = mailCache.get(cacheKey); + if (cached) return cached; + + const mails = await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const status = await client.status('INBOX', { messages: true }); + const total = status.messages || 0; + if (total === 0) return []; + + const start = Math.max(1, total - limit + 1); + const range = `${start}:${total}`; + const result: ReceivedMail[] = []; + + for await (const msg of client.fetch(range, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + + result.push({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + + result.sort((a, b) => b.date.getTime() - a.date.getTime()); + return result; + } finally { + mailbox.release(); + } + }); + + mailCache.set(cacheKey, mails, 60_000); + return mails; + } + + async fetchMailListStream( + account: UserMailAccount, + limit: number = 20, + beforeSeqno: number | null = null, + onMail: (mail: ReceivedMail) => void, + onDone: () => void, + onError: (err: Error) => void + ): Promise { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const status = await client.status('INBOX', { messages: true }); + const total = status.messages || 0; + if (total === 0) { onDone(); return; } + + let start: number, end: number; + if (beforeSeqno !== null) { + end = beforeSeqno - 1; + start = Math.max(1, beforeSeqno - limit); + } else { + start = Math.max(1, total - limit + 1); + end = total; + } + + if (end < 1 || start > end) { onDone(); return; } + + for await (const msg of client.fetch(`${start}:${end}`, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + + onMail({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + onDone(); + } finally { + mailbox.release(); + } + }); + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))); + } + } + + async getMailDetail(account: UserMailAccount, seqno: number): Promise { + const cacheKey = `mailDetail:${account.id}:${seqno}`; + const cached = mailCache.get(cacheKey); + if (cached) return cached; + + const detail = await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + const msg = await client.fetchOne(`${seqno}`, { + uid: true, + flags: true, + envelope: true, + bodyStructure: true, + source: true, + }); + if (!msg) return null; + + const parsed = await simpleParser(msg.source as Buffer); + + const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; + const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; + const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc; + + return { + id: `${account.id}-imap-${seqno}`, + messageId: parsed.messageId || `${seqno}`, + from: fromAddress?.text || 'Unknown', + to: toAddress?.text || '', + cc: ccAddress?.text, + subject: parsed.subject || '(제목 없음)', + date: parsed.date || new Date(), + htmlBody: parsed.html || '', + textBody: parsed.text || '', + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments: (parsed.attachments?.length || 0) > 0, + attachments: (parsed.attachments || []).map((att: any) => ({ + filename: att.filename || 'unnamed', + contentType: att.contentType || 'application/octet-stream', + size: att.size || 0, + })), + } as MailDetail; + } finally { + mailbox.release(); + } + }); + + if (detail) mailCache.set(cacheKey, detail, 300_000); + return detail; + } + + async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + await client.messageFlagsAdd(`${seqno}`, ['\\Seen']); + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + return { success: true, message: '읽음 처리 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + // \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등) + const folders = await client.list(); + const trashFolder = folders.find(f => f.specialUse === '\\Trash'); + + const mailbox = await client.getMailboxLock('INBOX'); + try { + if (trashFolder) { + await client.messageMove(`${seqno}`, trashFolder.path); + } else { + await client.messageDelete(`${seqno}`); + } + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`); + return { success: true, message: '휴지통으로 이동 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async listFolders(account: UserMailAccount): Promise> { + return imapConnectionPool.execute(account, async (client) => { + const folders = await client.list({ statusQuery: { unseen: true } }); + return folders + .filter(f => f.listed) + .map(f => ({ + path: f.path, + name: f.name, + unseen: (f as any).status?.unseen ?? 0, + })); + }); + } + + async streamMailsByFolder( + account: UserMailAccount, + folder: string, + limit: number = 20, + beforeSeqno: number | null = null, + onMail: (mail: ReceivedMail) => void, + onDone: () => void, + onError: (err: Error) => void + ): Promise { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const status = await client.status(folder, { messages: true }); + const total = status.messages || 0; + if (total === 0) { onDone(); return; } + + let start: number, end: number; + if (beforeSeqno !== null) { + end = beforeSeqno - 1; + start = Math.max(1, beforeSeqno - limit); + } else { + start = Math.max(1, total - limit + 1); + end = total; + } + if (end < 1 || start > end) { onDone(); return; } + + for await (const msg of client.fetch(`${start}:${end}`, { + uid: true, flags: true, envelope: true, bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure + ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') + : false; + onMail({ + id: `${account.id}-imap-${msg.seq}`, + messageId: msg.envelope?.messageId || `${msg.seq}`, + from: msg.envelope?.from?.[0] + ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() + : 'Unknown', + to: msg.envelope?.to?.[0]?.address || '', + subject: msg.envelope?.subject || '(제목 없음)', + date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), + preview: '', + isRead: msg.flags?.has('\\Seen') || false, + hasAttachments, + }); + } + onDone(); + } finally { + mailbox.release(); + } + }); + } catch (err) { + onError(err instanceof Error ? err : new Error(String(err))); + } + } + + async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> { + try { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock('INBOX'); + try { + await client.messageMove(`${seqno}`, targetFolder); + } finally { + mailbox.release(); + } + }); + mailCache.invalidateByPrefix(`mailList:${account.id}:`); + return { success: true, message: '이동 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '오류' }; + } + } + + async downloadAttachment( + account: UserMailAccount, + seqno: number, + partId: string, + res: import('express').Response, + folder: string = 'INBOX', + filenameHint?: string + ): Promise { + await imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const { meta, content } = await client.download(`${seqno}`, partId); + const rawFilename = filenameHint || (meta as any).filename || 'attachment'; + const encodedFilename = encodeURIComponent(rawFilename); + res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`); + res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream'); + if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size)); + await require('stream/promises').pipeline(content, res); + } finally { + mailbox.release(); + } + }); + } + + async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise> { + return imapConnectionPool.execute(account, async (client) => { + const mailbox = await client.getMailboxLock(folder); + try { + const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true }); + if (!msg || !msg.bodyStructure) return []; + const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = []; + function walk(node: any, part: string) { + const filename = node.parameters?.name || node.dispositionParameters?.filename; + if (filename && node.type !== 'text' && node.type !== 'multipart') { + result.push({ + partId: node.part || part, + filename, + contentType: `${node.type}/${node.subtype}`, + size: node.size || 0, + }); + } + if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`)); + } + walk(msg.bodyStructure, '1'); + return result; + } finally { + mailbox.release(); + } + }); + } + + async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> { + const decryptedPassword = encryptionService.decrypt(account.password); + const client = new ImapFlow({ + host: account.host, + port: account.port, + secure: account.useTls, + auth: { user: account.username, pass: decryptedPassword }, + logger: false as any, + tls: { rejectUnauthorized: false }, + }); + try { + await client.connect(); + await client.logout(); + return { success: true, message: 'IMAP 연결 성공' }; + } catch (err) { + let message = '연결 실패'; + if (err instanceof Error) { + const imapErr = err as any; + const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message; + const r = String(raw).toLowerCase(); + if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) { + message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.'; + } else if (r.includes('econnrefused') || r.includes('connection refused')) { + message = '연결 거부: 호스트 또는 포트를 확인하세요.'; + } else if (r.includes('enotfound') || r.includes('getaddrinfo')) { + message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.'; + } else if (r.includes('timeout') || r.includes('etimedout')) { + message = '연결 시간 초과: 서버가 응답하지 않습니다.'; + } else if (r.includes('self signed') || r.includes('certificate')) { + message = 'SSL 인증서 오류가 발생했습니다.'; + } else if (r.includes('econnreset')) { + message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.'; + } else { + message = raw; + } + } + return { success: false, message }; + } + } +} + +export const userMailImapService = new UserMailImapService(); diff --git a/backend-node/src/services/userMailSmtpService.ts b/backend-node/src/services/userMailSmtpService.ts new file mode 100644 index 00000000..aab8a6a8 --- /dev/null +++ b/backend-node/src/services/userMailSmtpService.ts @@ -0,0 +1,63 @@ +import nodemailer from 'nodemailer'; +import { encryptionService } from './encryptionService'; +import { UserMailAccount } from './userMailAccountService'; + +export interface SendMailDto { + to: string; + cc?: string; + subject: string; + html: string; + text?: string; + inReplyTo?: string; + references?: string; +} + +class UserMailSmtpService { + private getSmtpConfig(account: UserMailAccount) { + const decryptedPassword = encryptionService.decrypt(account.password); + // IMAP host에서 SMTP host 추론 + const smtpHost = account.host + .replace(/^imap\./, 'smtp.') + .replace(/^mail\./, 'smtp.'); + // 포트 추론: TLS → 465, plain → 587 + const port = account.useTls ? 465 : 587; + return { + host: smtpHost, + port, + secure: account.useTls, // 465: true, 587: false (STARTTLS) + auth: { user: account.username, pass: decryptedPassword }, + tls: { rejectUnauthorized: false }, + }; + } + + async sendMail(account: UserMailAccount, dto: SendMailDto): Promise<{ success: boolean; message: string }> { + try { + const transporter = nodemailer.createTransport(this.getSmtpConfig(account)); + await transporter.sendMail({ + from: `${account.displayName} <${account.email}>`, + to: dto.to, + cc: dto.cc, + subject: dto.subject, + html: dto.html, + text: dto.text, + inReplyTo: dto.inReplyTo, + references: dto.references, + }); + return { success: true, message: '발송 완료' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : '발송 실패' }; + } + } + + async testSmtpConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> { + try { + const transporter = nodemailer.createTransport(this.getSmtpConfig(account)); + await transporter.verify(); + return { success: true, message: 'SMTP 연결 성공' }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : 'SMTP 연결 실패' }; + } + } +} + +export const userMailSmtpService = new UserMailSmtpService(); diff --git a/backend-node/src/socket/messengerSocket.ts b/backend-node/src/socket/messengerSocket.ts new file mode 100644 index 00000000..048e841d --- /dev/null +++ b/backend-node/src/socket/messengerSocket.ts @@ -0,0 +1,171 @@ +import { Server, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; +import config from '../config/environment'; +import { messengerService } from '../services/messengerService'; +import { JwtPayload } from '../types/auth'; + +interface AuthenticatedSocket extends Socket { + data: { + userId: string; + userName: string; + companyCode: string; + }; +} + +// In-memory presence store: userId → { companyCode, status } +const presenceStore = new Map(); + +export function initMessengerSocket(io: Server) { + // JWT authentication middleware + io.use((socket, next) => { + const token = socket.handshake.auth?.token || socket.handshake.query?.token; + if (!token) { + return next(new Error('Authentication required')); + } + + try { + const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload; + socket.data.userId = decoded.userId; + socket.data.userName = decoded.userName; + socket.data.companyCode = decoded.companyCode || ''; + next(); + } catch (error) { + next(new Error('Invalid token')); + } + }); + + io.on('connection', async (socket: AuthenticatedSocket) => { + const { userId, companyCode } = socket.data; + console.log(`[Messenger] User connected: ${userId}`); + + // Join company presence room and broadcast online status + const presenceRoom = `${companyCode}:presence`; + socket.join(presenceRoom); + presenceStore.set(userId, { companyCode, status: 'online' }); + socket.to(presenceRoom).emit('user_status', { userId, status: 'online' }); + + // Send current online users list to newly connected socket + const currentPresence: Record = {}; + for (const [uid, info] of presenceStore.entries()) { + if (info.companyCode === companyCode) { + currentPresence[uid] = info.status; + } + } + socket.emit('presence_list', currentPresence); + + // set_status: client emits when tab focus changes + socket.on('set_status', (data: { status: 'online' | 'away' }) => { + const entry = presenceStore.get(userId); + if (entry) { + entry.status = data.status; + io.to(presenceRoom).emit('user_status', { userId, status: data.status }); + } + }); + + // join_rooms: subscribe to all user's rooms + socket.on('join_rooms', async () => { + try { + const roomIds = await messengerService.getUserRoomIds(userId, companyCode); + for (const roomId of roomIds) { + socket.join(`${companyCode}:${roomId}`); + } + socket.emit('rooms_joined', { roomIds }); + } catch (error) { + console.error('[Messenger] join_rooms error:', error); + socket.emit('error', { message: 'Failed to join rooms' }); + } + }); + + // send_message: save and broadcast + socket.on('send_message', async (data: { + room_id: number; + content: string; + message_type?: string; + parent_message_id?: number; + }) => { + try { + const message = await messengerService.sendMessage( + data.room_id, + userId, + companyCode, + data.content, + data.message_type || 'text', + data.parent_message_id + ); + io.to(`${companyCode}:${data.room_id}`).emit('new_message', message); + } catch (error) { + console.error('[Messenger] send_message error:', error); + socket.emit('error', { message: 'Failed to send message' }); + } + }); + + // message_read: update last_read_at + socket.on('message_read', async (data: { room_id: number }) => { + try { + await messengerService.markAsRead(data.room_id, userId); + io.to(`${companyCode}:${data.room_id}`).emit('user_read', { + room_id: data.room_id, + user_id: userId, + read_at: new Date().toISOString(), + }); + } catch (error) { + console.error('[Messenger] message_read error:', error); + } + }); + + // typing indicators + socket.on('typing_start', (data: { room_id: number }) => { + socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', { + room_id: data.room_id, + user_id: userId, + user_name: socket.data.userName, + }); + }); + + socket.on('typing_stop', (data: { room_id: number }) => { + socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', { + room_id: data.room_id, + user_id: userId, + user_name: socket.data.userName, + }); + }); + + // reactions + socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => { + try { + await messengerService.addReaction(data.message_id, userId, data.emoji); + io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', { + message_id: data.message_id, + user_id: userId, + emoji: data.emoji, + }); + } catch (error) { + console.error('[Messenger] add_reaction error:', error); + } + }); + + socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => { + try { + await messengerService.removeReaction(data.message_id, userId, data.emoji); + io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', { + message_id: data.message_id, + user_id: userId, + emoji: data.emoji, + }); + } catch (error) { + console.error('[Messenger] remove_reaction error:', error); + } + }); + + // join a specific room (e.g., after creating a new room) + socket.on('join_room', (data: { room_id: number }) => { + socket.join(`${companyCode}:${data.room_id}`); + }); + + socket.on('disconnect', () => { + console.log(`[Messenger] User disconnected: ${userId}`); + presenceStore.delete(userId); + io.to(presenceRoom).emit('user_status', { userId, status: 'offline' }); + }); + }); +} diff --git a/backend-node/src/socket/socketManager.ts b/backend-node/src/socket/socketManager.ts new file mode 100644 index 00000000..309ef6c0 --- /dev/null +++ b/backend-node/src/socket/socketManager.ts @@ -0,0 +1,11 @@ +import { Server } from 'socket.io'; + +let _io: Server | null = null; + +export function setIo(io: Server) { + _io = io; +} + +export function getIo(): Server | null { + return _io; +} diff --git a/backend-node/src/types/messenger.ts b/backend-node/src/types/messenger.ts new file mode 100644 index 00000000..49198adf --- /dev/null +++ b/backend-node/src/types/messenger.ts @@ -0,0 +1,97 @@ +// Messenger type definitions + +export interface MessengerRoom { + id: number; + company_code: string; + room_type: 'dm' | 'group' | 'channel'; + room_name: string | null; + created_by: string; + created_at: string; + updated_at: string; + // joined fields + last_message?: string; + last_message_at?: string; + last_sender_id?: string; + unread_count?: number; + participants?: MessengerParticipant[]; +} + +export interface MessengerParticipant { + id: number; + room_id: number; + user_id: string; + company_code: string; + last_read_at: string; + joined_at: string; + // joined fields + user_name?: string; + dept_name?: string; + photo?: string | null; +} + +export interface MessengerMessage { + id: number; + room_id: number; + sender_id: string; + company_code: string; + content: string | null; + message_type: 'text' | 'file' | 'system'; + parent_message_id: number | null; + created_at: string; + updated_at: string; + // joined fields + sender_name?: string; + sender_photo?: string | null; + reactions?: MessengerReaction[]; + files?: MessengerFile[]; + thread_count?: number; +} + +export interface MessengerReaction { + id: number; + message_id: number; + user_id: string; + emoji: string; + created_at: string; +} + +export interface MessengerFile { + id: number; + message_id: number; + original_name: string; + stored_name: string; + file_path: string; + file_size: number; + mime_type: string | null; + created_at: string; +} + +// Request types +export interface CreateRoomRequest { + room_type: 'dm' | 'group' | 'channel'; + room_name?: string; + participant_ids: string[]; +} + +export interface SendMessageRequest { + content: string; + message_type?: 'text' | 'file' | 'system'; + parent_message_id?: number; +} + +export interface AddReactionRequest { + message_id: number; + emoji: string; +} + +export interface UpdateRoomRequest { + room_name: string; +} + +export interface MessengerUser { + user_id: string; + user_name: string; + dept_name: string; + email?: string; + photo?: string | null; +} diff --git a/docs/ycshin-node/BIC[계획]-버튼-아이콘화.md b/docs/yc/BIC[계획]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[계획]-버튼-아이콘화.md rename to docs/yc/BIC[계획]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md b/docs/yc/BIC[맥락]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[맥락]-버튼-아이콘화.md rename to docs/yc/BIC[맥락]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BIC[체크]-버튼-아이콘화.md b/docs/yc/BIC[체크]-버튼-아이콘화.md similarity index 100% rename from docs/ycshin-node/BIC[체크]-버튼-아이콘화.md rename to docs/yc/BIC[체크]-버튼-아이콘화.md diff --git a/docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md b/docs/yc/BTN-일괄변경-탑씰-버튼스타일.md similarity index 100% rename from docs/ycshin-node/BTN-일괄변경-탑씰-버튼스타일.md rename to docs/yc/BTN-일괄변경-탑씰-버튼스타일.md diff --git a/docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md b/docs/yc/CCA[계획]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[계획]-카테고리-연속등록모드.md rename to docs/yc/CCA[계획]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md b/docs/yc/CCA[맥락]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[맥락]-카테고리-연속등록모드.md rename to docs/yc/CCA[맥락]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md b/docs/yc/CCA[체크]-카테고리-연속등록모드.md similarity index 100% rename from docs/ycshin-node/CCA[체크]-카테고리-연속등록모드.md rename to docs/yc/CCA[체크]-카테고리-연속등록모드.md diff --git a/docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md b/docs/yc/CTI[계획]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[계획]-카테고리-깊이구분.md rename to docs/yc/CTI[계획]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md b/docs/yc/CTI[맥락]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[맥락]-카테고리-깊이구분.md rename to docs/yc/CTI[맥락]-카테고리-깊이구분.md diff --git a/docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md b/docs/yc/CTI[체크]-카테고리-깊이구분.md similarity index 100% rename from docs/ycshin-node/CTI[체크]-카테고리-깊이구분.md rename to docs/yc/CTI[체크]-카테고리-깊이구분.md diff --git a/docs/yc/IMX[계획]-imap-메일기능확장.md b/docs/yc/IMX[계획]-imap-메일기능확장.md new file mode 100644 index 00000000..f282f0ea --- /dev/null +++ b/docs/yc/IMX[계획]-imap-메일기능확장.md @@ -0,0 +1,122 @@ +--- +name: IMX[계획] IMAP 메일 기능 확장 +description: 메일 삭제, SMTP 발송, 폴더 전환, 첨부파일 다운로드, 이동, 답장/전달 구현 +type: plan +--- + +# IMX 계획 — IMAP 메일 기능 확장 + +## 개요 + +기존 메일 조회/읽음처리만 되던 IMAP 페이지에 전체 메일 클라이언트 기능 추가. +nodemailer(이미 설치), imapflow(이미 설치), TipTap v2(신규 설치) 기반. + +## 현재 동작 + +- 계정 목록 조회 +- 메일 스트리밍 목록 +- 메일 상세 보기 +- 읽음 처리 +- 메일 삭제 (백엔드만, UI 버튼 있으나 실제 연동 확인 필요) + +## 변경 후 동작 + +- 좌측 패널: 폴더 목록 (INBOX, Sent, Trash, Spam 등) + 미읽음 수 +- 메일 상세 우측 버튼: 답장 / 전달 / 이동 / 삭제(→Trash) +- 하단 첨부파일 목록 + 다운로드 버튼 +- 우상단 `작성` 버튼 → Dialog (TipTap 에디터, to/cc/subject) +- 답장/전달 시 원문 인용 자동 삽입 + +## 시각적 예시 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 메일 관리 (IMAP) [작성] [계정추가] │ +├──────────────┬───────────────────┬───────────────────────────────┤ +│ [계정목록] │ [검색창] │ 제목: 보안 알림 │ +│ │ │ From: Google │ +│ Gmail │ ● Google 오후1:51 │ To: yechul@gmail.com │ +│ Wace │ 보안 알림 │ Date: 2026-03-27 │ +│ │ ● GitHub 오전9:48 │ [답장][전달][이동▼][삭제] │ +│ ───────── │ Sudo code │ ───────────────────────────── │ +│ [폴더목록] │ │ │ +│ INBOX (3) │ │ │ +│ Sent │ │ 📎 첨부파일 │ +│ Trash │ │ file.pdf (120KB) [다운로드] │ +│ Spam │ │ │ +└──────────────┴───────────────────┴───────────────────────────────┘ +``` + +## 아키텍처 + +```mermaid +graph TD + FE[page.tsx] -->|GET /folders| BE_CTRL[userMailController] + FE -->|POST /send| BE_CTRL + FE -->|POST /move| BE_CTRL + FE -->|GET /attachment| BE_CTRL + BE_CTRL --> IMAP[userMailImapService] + BE_CTRL --> SMTP[userMailSmtpService - 신규] + IMAP --> Pool[imapConnectionPool] + SMTP --> Nodemailer[nodemailer] +``` + +## 변경 파일 + +### 신규 +- `backend-node/src/services/userMailSmtpService.ts` — SMTP 발송 전용 + +### 수정 +- `backend-node/src/services/userMailImapService.ts` — 폴더목록, 이동, 첨부파일 추가 +- `backend-node/src/controllers/userMailController.ts` — 신규 엔드포인트 핸들러 +- `backend-node/src/routes/userMailRoutes.ts` — 신규 라우트 등록 +- `frontend/lib/api/userMail.ts` — 신규 API 함수 +- `frontend/app/(main)/mail/imap/page.tsx` — UI 전면 확장 + +## 신규 API 엔드포인트 + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/user-mail/accounts/:id/folders` | 폴더 목록 + 미읽음 수 | +| GET | `/user-mail/accounts/:id/folders/:folder/mails/stream` | 폴더별 메일 스트리밍 | +| POST | `/user-mail/accounts/:id/mails/:seqno/move` | 메일 이동 `{ targetFolder }` | +| GET | `/user-mail/accounts/:id/mails/:seqno/attachment/:partId` | 첨부파일 다운로드 (스트리밍) | +| POST | `/user-mail/accounts/:id/send` | 메일 발송 `{ to, cc, subject, html, text, inReplyTo?, references? }` | + +## 코드 설계 + +### userMailSmtpService.ts +```typescript +// SMTP 포트 추론: useTls true → 465, false → 587 +// Gmail: smtp.gmail.com, wace.me: mail.wace.me 또는 host 에서 도메인 추출 +// nodemailer createTransport + sendMail +// 답장: inReplyTo, references 헤더 설정 +``` + +### userMailImapService.ts 추가 메서드 +```typescript +listFolders(account): Promise<{ path, name, unseen }[]> // client.list({ statusQuery }) +moveMail(account, seqno, targetFolder): Promise // messageMove +downloadAttachment(account, seqno, partId, res): Promise // download() + pipeline(res) +``` + +### page.tsx 추가 UI +- `folders` state: 폴더 목록 +- `currentFolder` state: 현재 폴더 (기본 INBOX) +- `ComposeDialog`: TipTap 에디터 + to/cc/subject 필드 +- `composeMode`: 'new' | 'reply' | 'forward' +- 메일 상세 버튼: 답장, 전달, 이동(DropdownMenu), 삭제 + +## 예상 문제 + +1. **Gmail SMTP 포트**: Gmail은 587(STARTTLS) 또는 465(SSL). host에서 자동 추론. +2. **폴더명 인코딩**: 한글 폴더 등 UTF-7/UTF-8 혼용 → imapflow가 자동 처리 +3. **첨부파일 partId**: bodyStructure 파싱이 복잡 → `client.download(seqno, partId)` 직접 사용 +4. **TipTap SSR**: Next.js에서 dynamic import 필요 (`ssr: false`) + +## 설계 원칙 + +- SMTP 서비스는 IMAP 서비스와 완전 분리 (파일 분리) +- 첨부파일은 서버에 저장하지 않고 스트리밍으로 직접 응답 +- 답장/전달 인용: `
` + RFC 2822 헤더 표준 준수 +- TipTap은 dynamic import로 SSR 방지 diff --git a/docs/yc/IMX[맥락]-imap-메일기능확장.md b/docs/yc/IMX[맥락]-imap-메일기능확장.md new file mode 100644 index 00000000..553ef5bd --- /dev/null +++ b/docs/yc/IMX[맥락]-imap-메일기능확장.md @@ -0,0 +1,58 @@ +--- +name: IMX[맥락] IMAP 메일 기능 확장 +description: 왜 이 기능들을 추가하는가, 핵심 결정 근거 +type: context +--- + +# IMX 맥락 — IMAP 메일 기능 확장 + +## 왜 하는가 + +ERP 시스템에서 메일 확인만 되면 의미가 없음. 거래처 메일 수신 후 바로 답장, 견적서 첨부파일 저장, 담당자 전달까지 워크플로우가 연결되어야 실용적. + +## 핵심 결정 + 근거 + +### SMTP 서비스 분리 (`userMailSmtpService.ts`) +- IMAP(수신)과 SMTP(송신)은 프로토콜 자체가 다름 +- 파일 분리로 각각 독립적으로 교체/테스트 가능 +- nodemailer는 이미 설치됨 (추가 의존성 없음) + +### TipTap v2 선택 (메일 에디터) +- ProseMirror 기반 → 안정적, 확장 용이 +- `@tiptap/react` 공식 패키지 → Next.js 15 호환 +- Quill보다 번들 크기 작음 (~100KB vs ~200KB) +- dynamic import (`ssr: false`)로 SSR 문제 회피 + +### 첨부파일 스트리밍 +- 서버에 임시 저장하지 않음 → 디스크 절약, 보안 +- `imapflow client.download()` → `stream/promises pipeline()` → HTTP 응답 +- 대용량 파일도 메모리 부담 없음 + +### 메일 삭제 = Trash 이동 +- 즉시 삭제(`messageDelete`) 대신 Trash 폴더 이동 +- 실수로 삭제 시 복구 가능 +- Gmail/wace.me 모두 Trash 폴더 표준 지원 + +### 폴더 구조 표시 +- `client.list({ statusQuery: { unseen: true } })` 로 미읽음 수 포함 +- 폴더 클릭 시 기존 스트리밍 로직 재활용 (folder 파라미터 추가) + +### 답장/전달 RFC 준수 +- `inReplyTo`, `references` 헤더 → 메일 클라이언트에서 스레드로 묶임 +- `
` 인용 → Gmail/Outlook 모두 올바르게 렌더링 + +## 관련 파일 + +- `backend-node/src/services/userMailImapService.ts` — IMAP 수신 로직 +- `backend-node/src/services/imapConnectionPool.ts` — 커넥션 풀 (건드리지 않음) +- `backend-node/src/services/mailCache.ts` — TTL 캐시 (건드리지 않음) +- `frontend/app/(main)/mail/imap/page.tsx` — 메인 UI + +## 기술 참고 + +- imapflow `client.list()` → statusQuery 옵션으로 unseen 포함 +- imapflow `client.messageMove(seqno, folder)` → UID 기반 이동 +- imapflow `client.download(seqno, partId)` → ReadableStream 반환 +- nodemailer `createTransport({ host, port, secure, auth })` → `sendMail()` +- RFC 2822 §3.6.4: `In-Reply-To`, `References` 헤더 +- W3C HTML Threading: `
` 권장 diff --git a/docs/yc/IMX[체크]-imap-메일기능확장.md b/docs/yc/IMX[체크]-imap-메일기능확장.md new file mode 100644 index 00000000..c4be6ecf --- /dev/null +++ b/docs/yc/IMX[체크]-imap-메일기능확장.md @@ -0,0 +1,58 @@ +--- +name: IMX[체크] IMAP 메일 기능 확장 +description: 구현 및 검증 체크리스트 +type: checklist +--- + +# IMX 체크리스트 — IMAP 메일 기능 확장 + +## 공정 상태: 0% + +## 구현 체크리스트 + +### Unit A — 백엔드 서비스 +- [ ] `userMailImapService.ts`: `listFolders()` 추가 +- [ ] `userMailImapService.ts`: `streamMailsByFolder()` 추가 +- [ ] `userMailImapService.ts`: `moveMail()` 추가 +- [ ] `userMailImapService.ts`: `downloadAttachment()` 추가 +- [ ] `userMailSmtpService.ts` 신규 생성 (nodemailer 기반) +- [ ] TypeScript 에러 없음 + +### Unit B — 백엔드 컨트롤러/라우트 +- [ ] `userMailController.ts`: `listFolders`, `streamFolderMails`, `moveMail`, `downloadAttachment`, `sendMail` 핸들러 +- [ ] `userMailRoutes.ts`: 5개 신규 라우트 등록 +- [ ] TypeScript 에러 없음 + +### Unit C — 프론트엔드 API +- [ ] `userMail.ts`: `getUserMailFolders()` 추가 +- [ ] `userMail.ts`: `streamFolderMails()` 추가 +- [ ] `userMail.ts`: `moveUserMail()` 추가 +- [ ] `userMail.ts`: `sendUserMail()` 추가 +- [ ] `userMail.ts`: 첨부파일 다운로드 URL 헬퍼 추가 + +### Unit D — 프론트엔드 UI (TipTap 설치 포함) +- [ ] TipTap 패키지 설치 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`) +- [ ] 좌측 패널: 폴더 목록 + 미읽음 수 +- [ ] 폴더 클릭 → 해당 폴더 메일 스트리밍 +- [ ] 메일 상세: 답장/전달/이동/삭제 버튼 +- [ ] ComposeDialog: TipTap 에디터 + to/cc/subject +- [ ] 답장/전달 시 원문 인용 자동 삽입 +- [ ] 첨부파일 목록 + 다운로드 링크 +- [ ] TypeScript 에러 없음 + +## 검증 체크리스트 + +- [ ] 폴더 목록이 좌측 패널에 표시됨 (INBOX, Sent, Trash 등) +- [ ] 폴더 클릭 시 해당 폴더 메일이 로드됨 +- [ ] 메일 삭제 버튼 클릭 → Trash로 이동됨 +- [ ] 메일 이동 드롭다운 → 다른 폴더로 이동됨 +- [ ] 첨부파일 있는 메일에서 다운로드 버튼 동작 +- [ ] 새 메일 작성 → 발송 성공 +- [ ] 답장 → To 자동 입력, 원문 인용 포함 +- [ ] 전달 → 원문 전체 포함 + +## 변경 이력 + +| 일자 | 내용 | +|------|------| +| 2026-03-27 | PCC 작성, 구현 시작 | diff --git a/docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md b/docs/yc/LFC[계획]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[계획]-위치포맷-사용자설정.md rename to docs/yc/LFC[계획]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md b/docs/yc/LFC[맥락]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[맥락]-위치포맷-사용자설정.md rename to docs/yc/LFC[맥락]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md b/docs/yc/LFC[체크]-위치포맷-사용자설정.md similarity index 100% rename from docs/ycshin-node/LFC[체크]-위치포맷-사용자설정.md rename to docs/yc/LFC[체크]-위치포맷-사용자설정.md diff --git a/docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md b/docs/yc/MPN[계획]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[계획]-품번-수동접두어채번.md rename to docs/yc/MPN[계획]-품번-수동접두어채번.md diff --git a/docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md b/docs/yc/MPN[맥락]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[맥락]-품번-수동접두어채번.md rename to docs/yc/MPN[맥락]-품번-수동접두어채번.md diff --git a/docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md b/docs/yc/MPN[체크]-품번-수동접두어채번.md similarity index 100% rename from docs/ycshin-node/MPN[체크]-품번-수동접두어채번.md rename to docs/yc/MPN[체크]-품번-수동접두어채번.md diff --git a/docs/yc/MSN[계획]-메신저기능.md b/docs/yc/MSN[계획]-메신저기능.md new file mode 100644 index 00000000..f75eca41 --- /dev/null +++ b/docs/yc/MSN[계획]-메신저기능.md @@ -0,0 +1,212 @@ +# MSN[계획] 메신저 기능 개발 + +## 개요 + +벡스플로어 ERP에 내장 메신저 기능을 추가한다. Gmail 편지쓰기 스타일의 우측 하단 플로팅 모달로 동작하며, Socket.IO 기반 실시간 통신을 제공한다. 모든 화면에서 접근 가능하고, 동일 company_code 내 사용자끼리 1:1 DM / 그룹 채팅 / 채널 대화를 지원한다. + +--- + +## 현재 동작 + +- 메신저 기능 없음 +- 사내 커뮤니케이션 수단 부재 + +## 변경 후 동작 + +- 모든 화면 우측 하단에 메신저 FAB 버튼 고정 (z-index: 9999) +- FAB 클릭 시 Gmail 편지쓰기 스타일 모달 팝업 (우측 하단) +- 모달 좌측: 채팅방 목록 (DM / 그룹 / 채널 탭) +- 모달 우측: 채팅 영역 (메시지 입력, 파일 첨부, 이모지, 멘션, 스레드) +- Socket.IO로 실시간 메시지 수신 +- 읽지 않은 메시지 수 FAB 배지 표시 +- 토스트 알림 on/off 토글 (메신저 설정 내) + +--- + +## 시각적 예시 + +``` +┌─────────────────────────────────────────────────────┐ +│ 벡스플로어 화면 │ +│ │ +│ │ +│ ┌────────────────────┐ │ +│ │ 채팅방 목록 │ 채팅창 │ │ +│ │─────────────────── │ │ +│ │ DM 그룹 채널 │ │ +│ │ ───────────────── │ │ +│ │ 👤 김민호 ● │ │ +│ │ 👥 개발팀 │ │ +│ │ # 공지사항 │ │ +│ └────────────────────┘ │ +│ [💬 3] │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 아키텍처 + +```mermaid +graph TB + subgraph Frontend + FAB[MessengerFAB] --> Modal[MessengerModal] + Modal --> RoomList[RoomList 좌측 240px] + Modal --> ChatPanel[ChatPanel 우측 480px] + ChatPanel --> MessageList[MessageList] + ChatPanel --> MessageInput[MessageInput] + MessageInput --> FileUpload[파일 첨부] + MessageInput --> EmojiPicker[이모지] + MessageInput --> MentionDropdown[멘션 @] + end + + subgraph SocketIO + SocketClient[socket.io-client] <--> SocketServer[messengerSocket.ts] + end + + subgraph Backend + SocketServer --> MessengerService[messengerService.ts] + Route[messengerRoutes.ts] --> Controller[messengerController.ts] + Controller --> MessengerService + MessengerService --> DB[(PostgreSQL)] + FileRoute[파일 업로드] --> Multer[multerMessengerConfig.ts] + end + + Frontend <--> SocketIO + Frontend <--> Route +``` + +--- + +## 변경 파일 + +### 신규 생성 +| 파일 | 역할 | +|------|------| +| `backend-node/src/types/messenger.ts` | TypeScript 인터페이스 | +| `backend-node/src/services/messengerService.ts` | 비즈니스 로직 | +| `backend-node/src/controllers/messengerController.ts` | HTTP 핸들러 | +| `backend-node/src/routes/messengerRoutes.ts` | REST API 라우트 | +| `backend-node/src/socket/messengerSocket.ts` | Socket.IO 이벤트 핸들러 | +| `backend-node/src/config/multerMessengerConfig.ts` | 파일 업로드 설정 | +| `db/migrations/messenger_tables.sql` | DB 테이블 생성 | +| `frontend/components/messenger/MessengerFAB.tsx` | 플로팅 버튼 | +| `frontend/components/messenger/MessengerModal.tsx` | 메인 모달 컨테이너 | +| `frontend/components/messenger/RoomList.tsx` | 채팅방 목록 | +| `frontend/components/messenger/ChatPanel.tsx` | 채팅 영역 | +| `frontend/components/messenger/MessageItem.tsx` | 메시지 단일 아이템 | +| `frontend/components/messenger/MessageInput.tsx` | 입력창 | +| `frontend/components/messenger/NewRoomModal.tsx` | 방 생성 모달 | +| `frontend/components/messenger/UserAvatar.tsx` | 프로필 아바타 | +| `frontend/hooks/useMessenger.ts` | 메신저 상태 훅 | +| `frontend/hooks/useMessengerSocket.ts` | Socket.IO 훅 | +| `frontend/contexts/MessengerContext.tsx` | 전역 메신저 상태 | + +### 수정 +| 파일 | 변경 내용 | +|------|-----------| +| `backend-node/src/app.ts` | Socket.IO 서버 초기화, messengerRoutes 등록 | +| `frontend/app/(main)/layout.tsx` | ``, `` 추가 | + +--- + +## 코드 설계 + +### DB 스키마 +```sql +-- 채팅방 +messenger_rooms ( + room_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_code VARCHAR(50) NOT NULL, + room_type VARCHAR(10) NOT NULL CHECK (room_type IN ('dm', 'group', 'channel')), + room_name VARCHAR(100), + created_by VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +) + +-- 참여자 +messenger_participants ( + room_id UUID REFERENCES messenger_rooms(room_id), + user_id VARCHAR(100) NOT NULL, + company_code VARCHAR(50) NOT NULL, + joined_at TIMESTAMP DEFAULT NOW(), + last_read_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (room_id, user_id) +) + +-- 메시지 +messenger_messages ( + message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + room_id UUID REFERENCES messenger_rooms(room_id), + company_code VARCHAR(50) NOT NULL, + sender_id VARCHAR(100) NOT NULL, + content TEXT, + message_type VARCHAR(10) DEFAULT 'text' CHECK (message_type IN ('text', 'file', 'system')), + parent_message_id UUID REFERENCES messenger_messages(message_id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + is_deleted BOOLEAN DEFAULT FALSE +) + +-- 이모지 리액션 +messenger_reactions ( + message_id UUID REFERENCES messenger_messages(message_id), + user_id VARCHAR(100) NOT NULL, + emoji VARCHAR(10) NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (message_id, user_id, emoji) +) + +-- 파일 첨부 +messenger_files ( + file_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID REFERENCES messenger_messages(message_id), + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +) +``` + +### Socket.IO 이벤트 +| 이벤트 | 방향 | 설명 | +|--------|------|------| +| `join_rooms` | Client→Server | 참여 중인 방 전체 구독 | +| `send_message` | Client→Server | 메시지 전송 | +| `new_message` | Server→Client | 새 메시지 수신 | +| `message_read` | Client→Server | 읽음 처리 | +| `user_online` | Server→Client | 온라인 상태 변경 | +| `typing_start/stop` | Client↔Server | 타이핑 표시 | +| `add_reaction` | Client→Server | 이모지 리액션 추가 | +| `reaction_updated` | Server→Client | 리액션 업데이트 | + +### REST API +| Method | URL | 설명 | +|--------|-----|------| +| GET | `/api/messenger/rooms` | 내 채팅방 목록 | +| POST | `/api/messenger/rooms` | 채팅방 생성 | +| GET | `/api/messenger/rooms/:roomId/messages` | 메시지 히스토리 | +| POST | `/api/messenger/rooms/:roomId/read` | 읽음 처리 | +| POST | `/api/messenger/files/upload` | 파일 업로드 | +| GET | `/api/messenger/files/:fileId` | 파일 다운로드 | +| GET | `/api/messenger/users` | 회사 내 사용자 목록 | +| PUT | `/api/messenger/rooms/:roomId` | 방 이름/설정 수정 | + +--- + +## 예상 문제 + +1. **Socket.IO + Next.js**: Next.js는 기본적으로 HTTP 서버를 추상화하므로, Express HTTP 서버에 Socket.IO를 붙이고 프론트엔드는 백엔드 포트로 직접 연결 +2. **JWT 인증 with Socket.IO**: handshake 시 Authorization 헤더 또는 auth 옵션으로 토큰 전달, 서버에서 미들웨어로 검증 +3. **채널 분리 가능성**: `room_type = 'channel'` 쿼리 조건으로만 필터링하므로, 채널 UI/라우트만 제거하면 기능 완전 제거 가능 + +--- + +## 설계 원칙 + +- 기존 MVC + Service 패턴 준수 +- 모든 쿼리에 `company_code` 필터 적용 (멀티테넌시) +- 채널은 `room_type` 값으로만 분리 — 코드 결합도 최소화 +- FAB/모달은 기존 레이아웃 DOM에 독립적으로 렌더링 (Portal 또는 최상단 마운트) diff --git a/docs/yc/MSN[맥락]-메신저기능.md b/docs/yc/MSN[맥락]-메신저기능.md new file mode 100644 index 00000000..cd83c315 --- /dev/null +++ b/docs/yc/MSN[맥락]-메신저기능.md @@ -0,0 +1,61 @@ +# MSN[맥락] 메신저 기능 개발 + +## 왜 하는가 + +벡스플로어 ERP 내에서 사용자 간 실시간 커뮤니케이션 수단이 없다. 업무 맥락을 ERP 밖(카카오톡, 슬랙 등)으로 내보내지 않고 시스템 안에서 처리할 수 있도록 내장 메신저를 도입한다. + +--- + +## 핵심 결정 및 근거 + +| 결정 | 근거 | +|------|------| +| Socket.IO 신규 도입 | HTTP 폴링은 메신저에 부적합. 실시간 타이핑 표시, 온라인 상태, 즉각적인 메시지 수신이 필요 | +| Gmail 스타일 우측 하단 모달 | 업무 화면을 방해하지 않고 언제든 접근 가능. 별도 페이지 이동 없이 사용 | +| DM + 그룹 + 채널 1차 구현 | 채널은 `room_type` 값으로만 분리하여 나중에 제거 용이하게 설계 | +| company_code 격리 | 기존 멀티테넌시 패턴과 동일하게 적용. 회사 간 데이터 유출 방지 | +| 파일 업로드: multer 로컬 | 기존 메일 첨부파일과 동일한 방식. 인프라 추가 없이 일관성 유지 | +| 프로필: photo BLOB 활용 | `user_info.photo` 컬럼에 이미 이미지 저장됨. 없으면 이름 첫 글자 원형 아바타 | +| 토스트 알림 on/off | 집중 업무 시 알림 방해 방지. localStorage에 설정 저장 | + +--- + +## 관련 파일 + +### 참고 패턴 (기존 코드) +| 파일 | 참고 목적 | +|------|-----------| +| `backend-node/src/config/multerConfig.ts` | 파일 업로드 설정 패턴 | +| `backend-node/src/services/authService.ts` | user_info.photo BLOB → base64 변환 패턴 | +| `backend-node/src/middleware/authMiddleware.ts` | JWT 인증 미들웨어 (Socket.IO handshake에도 동일 로직 적용) | +| `frontend/components/mail/` | UI 컴포넌트 구조 참고 | +| `frontend/hooks/use-toast.ts` | 기존 토스트 훅 사용 | +| `backend-node/src/app.ts` | 라우트 등록 및 미들웨어 설정 위치 | + +--- + +## 기술 참고 + +### Socket.IO JWT 인증 +```typescript +// 서버: handshake 시 토큰 검증 +io.use((socket, next) => { + const token = socket.handshake.auth.token; + const user = verifyJWT(token); + socket.data.user = user; + next(); +}); + +// 클라이언트: 연결 시 토큰 전달 +const socket = io(BACKEND_URL, { + auth: { token: localStorage.getItem('token') } +}); +``` + +### 채널 분리 설계 +채널 기능은 `room_type = 'channel'` 조건으로만 분리되어 있음. +제거 시: RoomList의 채널 탭 UI 제거 + `/api/messenger/rooms?type=channel` 호출 제거만으로 완전 비활성화 가능. DB 스키마 변경 불필요. + +### 멀티테넌시 적용 +모든 쿼리에 `WHERE company_code = $n` 조건 필수 적용. +Socket.IO 룸 네이밍: `{company_code}:{room_id}` 형식으로 회사별 격리. diff --git a/docs/yc/MSN[체크]-메신저기능.md b/docs/yc/MSN[체크]-메신저기능.md new file mode 100644 index 00000000..b35908e6 --- /dev/null +++ b/docs/yc/MSN[체크]-메신저기능.md @@ -0,0 +1,97 @@ +# MSN[체크] 메신저 기능 개발 + +## 공정 상태: 90% (1차 구현 완료, 2차 테스트 대기) + +--- + +## 구현 체크리스트 + +### Phase 1: DB & 백엔드 기반 +- [x] `db/migrations/messenger_tables.sql` 작성 및 실행 +- [x] `backend-node/src/types/messenger.ts` 타입 정의 +- [x] `backend-node/src/services/messengerService.ts` 구현 + - [x] getRooms (내 채팅방 목록) + - [x] createRoom (DM / 그룹 / 채널) + - [x] getMessages (메시지 히스토리, 페이지네이션) + - [x] sendMessage + - [x] markAsRead + - [x] getCompanyUsers (사용자 목록) + - [x] addReaction / removeReaction +- [x] `backend-node/src/controllers/messengerController.ts` 구현 +- [x] `backend-node/src/routes/messengerRoutes.ts` 구현 +- [x] `backend-node/src/config/multerMessengerConfig.ts` 구현 (파일 업로드) +- [x] `backend-node/src/socket/messengerSocket.ts` 구현 + - [x] JWT 인증 미들웨어 + - [x] join_rooms 이벤트 + - [x] send_message 이벤트 + - [x] message_read 이벤트 + - [x] typing_start / typing_stop 이벤트 + - [x] add_reaction 이벤트 + - [x] 온라인 상태 관리 (connect / disconnect) +- [x] `backend-node/src/app.ts` Socket.IO 초기화 및 라우트 등록 + +### Phase 2: 프론트엔드 컴포넌트 +- [x] `frontend/contexts/MessengerContext.tsx` (전역 상태) +- [x] `frontend/hooks/useMessengerSocket.ts` (Socket.IO 연결 관리) +- [x] `frontend/hooks/useMessenger.ts` (채팅방/메시지 React Query) +- [x] `frontend/components/messenger/UserAvatar.tsx` (프로필 이미지 / 이름 첫 글자) +- [x] `frontend/components/messenger/MessengerFAB.tsx` (플로팅 버튼 + 배지) +- [x] `frontend/components/messenger/MessengerModal.tsx` (메인 모달 컨테이너) +- [x] `frontend/components/messenger/RoomList.tsx` (채팅방 목록 + DM/그룹/채널 탭) +- [x] `frontend/components/messenger/ChatPanel.tsx` (채팅 영역) +- [x] `frontend/components/messenger/MessageItem.tsx` (메시지 + 리액션 + 스레드 버튼) +- [x] `frontend/components/messenger/MessageInput.tsx` (입력창 + 파일 + 이모지 + 멘션) +- [x] `frontend/components/messenger/NewRoomModal.tsx` (방 생성 모달) +- [x] `frontend/components/messenger/MessengerSettings.tsx` (토스트 알림 on/off 등) +- [x] `frontend/app/(main)/layout.tsx` MessengerProvider + MessengerFAB 추가 + +--- + +## 검증 체크리스트 + +### 기본 동작 +- [ ] FAB 버튼이 모든 페이지에서 우측 하단 고정 (z-index 최상위) +- [ ] FAB 클릭 시 모달 열기/닫기 동작 +- [ ] 모달 크기: 좌측 240px + 우측 480px + +### 채팅 +- [ ] DM 방 생성 (사용자 선택 → 방 생성) +- [ ] 그룹 방 생성 (여러 사용자 선택 → 방 이름 입력 → 생성) +- [ ] 채널 방 생성 +- [ ] 메시지 전송 및 실시간 수신 (Socket.IO) +- [ ] 메시지 히스토리 로드 (스크롤 시 이전 메시지) + +### 부가 기능 +- [ ] 파일 첨부 업로드/다운로드 +- [ ] 이모지 리액션 추가/제거 +- [ ] 멘션(@) 자동완성 드롭다운 +- [ ] 스레드 답글 +- [ ] 타이핑 표시 ("김민호님이 입력 중...") + +### 알림 +- [ ] 읽지 않은 메시지 수 FAB 배지 표시 +- [ ] 다른 방 메시지 수신 시 토스트 알림 +- [ ] 토스트 알림 on/off 토글 동작 +- [ ] 토스트 설정값 localStorage 저장/복원 + +### 프로필 +- [ ] photo 있는 사용자: 원형 이미지 표시 +- [ ] photo 없는 사용자: 이름 첫 글자 원형 아바타 표시 + +### 멀티테넌시 +- [ ] 다른 company_code 사용자 목록에 미노출 +- [ ] 다른 회사 채팅방 접근 불가 + +--- + +## 정리 + +- [ ] DB 체크포인트 파일 삭제 (2차 테스트 완료 후) + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-30 | 최초 설계 확정 | diff --git a/docs/ycshin-node/MST[계획]-다중선택-라벨표시.md b/docs/yc/MST[계획]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[계획]-다중선택-라벨표시.md rename to docs/yc/MST[계획]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md b/docs/yc/MST[맥락]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[맥락]-다중선택-라벨표시.md rename to docs/yc/MST[맥락]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/MST[체크]-다중선택-라벨표시.md b/docs/yc/MST[체크]-다중선택-라벨표시.md similarity index 100% rename from docs/ycshin-node/MST[체크]-다중선택-라벨표시.md rename to docs/yc/MST[체크]-다중선택-라벨표시.md diff --git a/docs/ycshin-node/PGN[계획]-페이징-직접입력.md b/docs/yc/PGN[계획]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[계획]-페이징-직접입력.md rename to docs/yc/PGN[계획]-페이징-직접입력.md diff --git a/docs/ycshin-node/PGN[맥락]-페이징-직접입력.md b/docs/yc/PGN[맥락]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[맥락]-페이징-직접입력.md rename to docs/yc/PGN[맥락]-페이징-직접입력.md diff --git a/docs/ycshin-node/PGN[체크]-페이징-직접입력.md b/docs/yc/PGN[체크]-페이징-직접입력.md similarity index 100% rename from docs/ycshin-node/PGN[체크]-페이징-직접입력.md rename to docs/yc/PGN[체크]-페이징-직접입력.md diff --git a/docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md b/docs/yc/RFO[계획]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[계획]-렉구조-층필수해제.md rename to docs/yc/RFO[계획]-렉구조-층필수해제.md diff --git a/docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md b/docs/yc/RFO[맥락]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[맥락]-렉구조-층필수해제.md rename to docs/yc/RFO[맥락]-렉구조-층필수해제.md diff --git a/docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md b/docs/yc/RFO[체크]-렉구조-층필수해제.md similarity index 100% rename from docs/ycshin-node/RFO[체크]-렉구조-층필수해제.md rename to docs/yc/RFO[체크]-렉구조-층필수해제.md diff --git a/docs/yc/UML[계획]-user-mail.md b/docs/yc/UML[계획]-user-mail.md new file mode 100644 index 00000000..9997e10e --- /dev/null +++ b/docs/yc/UML[계획]-user-mail.md @@ -0,0 +1,169 @@ +# UML[계획] - 사용자 메일 관리 시스템 + +## 개요 + +벡스플로우(Vexflow) 사용자 메일 관리 페이지 구현 프로젝트입니다. 외부 메일 서버(POP3/IMAP)와 연동하여 사용자가 본인의 메일 계정을 등록하고 벡스플로우 내에서 메일을 조회할 수 있는 기능을 제공합니다. + +### 현재 동작 +- Admin 메일 시스템만 존재 +- JSON 파일 기반 저장소 +- 사용자 구분 없음 + +### 변경 후 동작 +- 사용자가 외부 메일 계정(IMAP 또는 POP3) 등록 +- 벡스플로우에서 해당 계정의 메일 조회 +- PostgreSQL 기반 계정 저장 및 관리 +- 사용자별 격리(user_id 기반) + +--- + +## 아키텍처 + +``` +┌─────────────┐ +│ 사용자 │ +│ (Frontend) │ +└──────┬──────┘ + │ + ├─→ /mail/imap 페이지 + └─→ /mail/pop3 페이지 + │ + ↓ + ┌──────────────────┐ + │ userMail.ts │ (API 클라이언트) + │ (lib/api/) │ + └────────┬─────────┘ + │ + ↓ + ┌────────────────────────────┐ + │ /api/user-mail/* 라우트 │ + │ (userMailController) │ + └────────┬───────────────────┘ + │ + ┌───────┴────────┐ + │ │ + ↓ ↓ + ┌────────────────┐ ┌──────────────┐ + │ userMailAccount│ │ userMailImap │ + │ Service │ │ Service │ + │ (PostgreSQL) │ │ (IMAP) │ + └────────────────┘ │ │ + └──────────────┘ + │ + ↓ + ┌──────────────────┐ + │ 외부 IMAP 서버 │ + └──────────────────┘ + + 또는 + + ┌──────────────────┐ + │ userMailPop3 │ + │ Service │ + │ (POP3) │ + └──────────────────┘ + │ + ↓ + ┌──────────────────┐ + │ 외부 POP3 서버 │ + └──────────────────┘ +``` + +--- + +## 신규 파일 목록 + +### 백엔드 (Node.js/Express) + +| 파일 경로 | 역할 | +|----------|------| +| `src/services/userMailAccountService.ts` | DB 계정 관리 (생성, 조회, 삭제, 수정) | +| `src/services/userMailImapService.ts` | IMAP 프로토콜 연결 및 메일 조회 | +| `src/services/userMailPop3Service.ts` | POP3 프로토콜 연결 및 메일 조회 | +| `src/controllers/userMailController.ts` | API 엔드포인트 처리 | +| `src/routes/userMailRoutes.ts` | 라우트 정의 | + +### 프론트엔드 (React/TypeScript) + +| 파일 경로 | 역할 | +|----------|------| +| `frontend/lib/api/userMail.ts` | API 클라이언트 | +| `frontend/app/(main)/mail/imap/page.tsx` | IMAP 메일 관리 페이지 | +| `frontend/app/(main)/mail/pop3/page.tsx` | POP3 메일 관리 페이지 | + +--- + +## 수정 파일 목록 + +| 파일 경로 | 변경 사항 | +|----------|---------| +| `src/runMigration.ts` | 마이그레이션 스크립트에 user_mail_accounts 테이블 추가 | +| `src/app.ts` | userMailRoutes 등록 | +| `src/components/AdminPageRenderer.tsx` | /mail/imap, /mail/pop3 페이지 하드코딩 등록 (2줄) | + +--- + +## 데이터베이스 스키마 + +### user_mail_accounts 테이블 + +```sql +CREATE TABLE user_mail_accounts ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')), + host VARCHAR(255) NOT NULL, + port INT NOT NULL DEFAULT 993, + use_tls BOOLEAN DEFAULT TRUE, + username VARCHAR(255) NOT NULL, + password TEXT NOT NULL, -- 암호화됨 (encryptionService 사용) + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, protocol, host, username) +); +``` + +--- + +## 설계 원칙 + +### 1. 사용자 격리 +- 모든 API 요청에서 현재 사용자의 user_id 검증 +- 다른 사용자의 계정/메일 접근 불가 + +### 2. 프로토콜별 서비스 분리 +- userMailImapService.ts: IMAP 전용 +- userMailPop3Service.ts: POP3 전용 +- 각 서비스는 독립적으로 동작 + +### 3. 기존 기능 재활용 +- `encryptionService`: 비밀번호 암호화/복호화 +- `mailparser`: 메일 본문 파싱 +- `imap` 패키지: IMAP 연결(기존 mailReceiveBasicService 참조) + +### 4. 기존 Admin 메일 시스템과 분리 +- 새로운 테이블, 서비스, 라우트로 완전 독립 +- JSON 파일 기반 방식 미사용 + +--- + +## 주요 API 엔드포인트 + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/user-mail/accounts` | 새 계정 등록 | +| GET | `/api/user-mail/accounts` | 사용자 계정 목록 | +| GET | `/api/user-mail/accounts/:id` | 계정 상세 조회 | +| PUT | `/api/user-mail/accounts/:id` | 계정 수정 | +| DELETE | `/api/user-mail/accounts/:id` | 계정 삭제 | +| POST | `/api/user-mail/accounts/:id/test` | 연결 테스트 | +| GET | `/api/user-mail/accounts/:id/mails` | 메일 목록 조회 | + +--- + +## 변경 이력 + +| 날짜 | 버전 | 내용 | +|------|------|------| +| 2026-03-27 | v1.0 | 초안 작성 | diff --git a/docs/yc/UML[맥락]-user-mail.md b/docs/yc/UML[맥락]-user-mail.md new file mode 100644 index 00000000..7b1f54fe --- /dev/null +++ b/docs/yc/UML[맥락]-user-mail.md @@ -0,0 +1,147 @@ +# UML[맥락] - 사용자 메일 관리 시스템 + +## 프로젝트 배경 + +### 추진 이유 +- 팀장 지시로 POP3 구현 필요 +- IMAP 허용 여부 확인 대기 중 +- 두 프로토콜 모두 구현 후 비교하여 최적 솔루션 채택 + +--- + +## 핵심 기술 결정 사항 + +### 1. 페이지 등록 방식: 하드코딩 +**선택**: 하드코딩 (AdminPageRenderer.tsx에 직접 등록) + +**사유**: +- 컴포넌트 레지스트리에 추가할 권한 없음 +- 간단한 추가 작업으로 빠른 구현 가능 + +**구현**: +```typescript +// AdminPageRenderer.tsx에 2줄 추가 +{path: '/mail/imap', label: '메일(IMAP)', component: () => }, +{path: '/mail/pop3', label: '메일(POP3)', component: () => }, +``` + +--- + +### 2. 저장소: PostgreSQL (Admin 메일과 완전 분리) + +**선택**: PostgreSQL `user_mail_accounts` 테이블 + +**사유**: +- Admin 메일 시스템(JSON 파일 기반)과 완전 독립 +- 사용자별 격리 용이 (user_id 기반) +- 확장성 및 성능 이점 + +**결과**: +- 기존 Admin 메일: JSON 파일 유지 +- 신규 사용자 메일: PostgreSQL 관리 + +--- + +### 3. POP3 메일 삭제 정책: 서버 유지 + +**선택**: DELE 명령 미호출 (서버 메일 유지) + +**사유**: +- 데이터 손실 방지 +- 사용자 실수로 인한 피해 최소화 +- 벡스플로우는 조회만 수행 + +**구현**: +- `userMailPop3Service.ts`에서 RETR 후 DELE 호출 안 함 +- 서버의 자동 정리 정책에 의존 + +--- + +### 4. 페이지별 프로토콜 고정 + +**선택**: 페이지당 프로토콜 1개로 제한 + +**구현**: +- `/mail/imap` → IMAP 계정만 표시/관리 +- `/mail/pop3` → POP3 계정만 표시/관리 + +**사유**: +- UI 단순화 +- 프로토콜별 메일 구조 차이 처리 용이 +- 사용자 혼동 최소화 + +--- + +## 관련 기존 코드 참조 + +### mailReceiveBasicService.ts +- IMAP 연결 및 메일 조회 로직 +- 메일 파싱 및 저장 방식 +- Error handling 패턴 + +**참조 사항**: +```typescript +// IMAP 연결 구조, 메일 검색 쿼리, 메일 수신 처리 방식 +``` + +### encryptionService.ts +- 비밀번호 암호화/복호화 +- DB 저장 시 암호화, 조회 시 복호화 + +**사용 방식**: +```typescript +// 저장: encryptionService.encrypt(password) +// 조회: encryptionService.decrypt(encrypted_password) +``` + +### AdminPageRenderer.tsx +- 기존 페이지 하드코딩 구조 +- 페이지 등록 형식 및 라벨 지정 방식 + +**추가 위치**: +```typescript +// 기존 페이지 목록에 /mail/imap, /mail/pop3 추가 +``` + +--- + +## 기술 스택 및 패키지 + +### 기존 패키지 (재활용) +| 패키지 | 버전 | 용도 | +|--------|------|------| +| `imap` | - | IMAP 연결 | +| `mailparser` | - | 메일 파싱 | +| `pg` | - | PostgreSQL 클라이언트 | + +### 신규 패키지 +| 패키지 | 버전 | 용도 | +|--------|------|------| +| `node-pop3` | latest | POP3 연결 | + +--- + +## 핵심 고려 사항 + +### 보안 +1. 메일 계정 비밀번호는 항상 암호화 상태로 저장 +2. 사용자 격리: user_id 기반 접근 제어 +3. 외부 서버 연결 정보는 민감: 환경변수 활용 + +### 성능 +1. 메일 조회는 페이지네이션 처리 +2. 연결 테스트는 별도 API (현재 메일 검색과 분리) +3. 대량 메일 처리 시 비동기 처리 + +### 에러 처리 +1. 네트워크 오류: 재시도 로직 +2. 인증 실패: 명확한 에러 메시지 제공 +3. DB 오류: 트랜잭션 롤백 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-03-27 | 초안 작성 | diff --git a/docs/yc/UML[체크]-user-mail.md b/docs/yc/UML[체크]-user-mail.md new file mode 100644 index 00000000..822a994d --- /dev/null +++ b/docs/yc/UML[체크]-user-mail.md @@ -0,0 +1,161 @@ +# UML[체크] - 사용자 메일 관리 시스템 + +## 공정 상태 +**진행률: 90%** (IMAP 완성, POP3 미구현) + +--- + +## 구현 체크리스트 + +### 데이터베이스 +- [x] DB 마이그레이션 작성 (user_mail_accounts 테이블 생성) + +### 패키지 설치 +- [ ] npm install node-pop3 (설치됨, 서비스 미구현) + +### 백엔드 서비스 계층 +- [x] userMailAccountService.ts (DB CRUD) +- [x] userMailImapService.ts (IMAP 프로토콜) +- [x] userMailSmtpService.ts (SMTP 발송) +- [x] imapConnectionPool.ts (IMAP 연결 풀) +- [x] mailCache.ts (메일 캐시) +- [ ] userMailPop3Service.ts (POP3 프로토콜 - 미구현) + +### 백엔드 API 계층 +- [x] userMailController.ts (요청 처리) +- [x] userMailRoutes.ts (라우트 정의) +- [x] app.ts에 userMailRoutes 등록 (`/api/user-mail`) + +### 프론트엔드 API 클라이언트 +- [x] frontend/lib/api/userMail.ts + +### 프론트엔드 페이지 +- [x] frontend/app/(main)/mail/imap/page.tsx +- [x] frontend/app/(main)/mail/imap/ComposeDialog.tsx (메일 작성) +- [ ] frontend/app/(main)/mail/pop3/page.tsx (미구현) + +### 페이지 등록 +- [x] AdminPageRenderer.tsx에 /mail/imap 등록 +- [ ] AdminPageRenderer.tsx에 /mail/pop3 등록 (미구현) + +--- + +## 구현된 IMAP 기능 + +### 계정 관리 +- [x] 계정 추가 (연결 테스트 후 저장) +- [x] 계정 수정 +- [x] 계정 삭제 +- [x] 연결 테스트 (저장 전 자동 + 수동) + +### 메일 조회 +- [x] SSE 스트리밍으로 메일 목록 로드 (20개씩) +- [x] 이전 메일 더 보기 (무한 스크롤 방식) +- [x] 메일 상세 조회 (HTML/텍스트 본문) +- [x] 폴더별 메일 조회 (INBOX, 휴지통, 스팸 등) +- [x] 새로고침 버튼 + +### 메일 관리 +- [x] 읽음 처리 (클릭 시 자동, IMAP \Seen 플래그) +- [x] 메일 삭제 (\Trash 특수 폴더로 이동 - Gmail 호환) +- [x] 메일 이동 (폴더 간 이동) + +### 첨부파일 +- [x] 첨부파일 목록 표시 (pill 형태) +- [x] 첨부파일 다운로드 (ReadableStream 진행바 표시) +- [x] Content-Length 헤더 지원 (정확한 진행률) + +### 발신 +- [x] 메일 작성 / 발송 (SMTP) +- [x] 답장 (Re: 제목, inReplyTo 헤더) +- [x] 전달 (Fwd: 제목, 원본 본문 인용) + +### UI +- [x] 3단 패널 레이아웃 (계정 / 메일 목록 / 상세) +- [x] 폴더 목록 (unseen 카운트 표시) +- [x] 읽음/삭제 후 unseen 카운트 자동 갱신 +- [x] 검색 (제목/발신자 클라이언트 필터) + +--- + +## 검증 체크리스트 + +### 데이터베이스 +- [x] `user_mail_accounts` 테이블 존재 확인 +- [x] 테이블 스키마 정확성 확인 + +### 계정 관리 API +- [x] POST `/api/user-mail/accounts` - 계정 생성 +- [x] GET `/api/user-mail/accounts` - 사용자 계정 목록 +- [x] PUT `/api/user-mail/accounts/:id` - 계정 수정 +- [x] DELETE `/api/user-mail/accounts/:id` - 계정 삭제 +- [x] POST `/api/user-mail/accounts/:id/test` - 연결 테스트 +- [x] POST `/api/user-mail/test-connection` - 직접 연결 테스트 + +### 메일 API +- [x] GET `/api/user-mail/accounts/:id/mails/stream` - 스트리밍 목록 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno` - 상세 조회 +- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/mark-read` - 읽음 처리 +- [x] DELETE `/api/user-mail/accounts/:id/mails/:seqno` - 삭제 (휴지통 이동) +- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/move` - 이동 +- [x] GET `/api/user-mail/accounts/:id/folders` - 폴더 목록 +- [x] GET `/api/user-mail/accounts/:id/folders/:folder/mails/stream` - 폴더별 스트리밍 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachments` - 첨부파일 목록 +- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachment/:partId` - 첨부파일 다운로드 +- [x] POST `/api/user-mail/accounts/:id/send` - 메일 발송 + +### 사용자 격리 검증 +- [x] 모든 쿼리에 WHERE user_id = $n 포함 (DB 레벨 강제) +- [x] 다른 user_id로 계정 접근 시 404 반환 + +### 프론트엔드 페이지 +- [x] `/mail/imap` 페이지 접속 및 동작 +- [x] Gmail IMAP 연동 확인 +- [x] 메일 목록 → 상세 → 읽음 처리 +- [x] 첨부파일 다운로드 진행바 +- [x] 메일 삭제 → Gmail 휴지통 이동 확인 +- [x] 답장/전달 발송 확인 + +--- + +## 알려진 이슈 및 주의사항 + +### 1. 메일 삭제 방식 +- `\Trash` 특수 폴더로 이동 (EXPUNGE 아님) +- Gmail 호환: `[Gmail]/휴지통`으로 자동 라우팅 +- 폴더 없으면 `messageDelete` fallback (영구 삭제 주의) + +### 2. 첨부파일 진행바 +- Content-Length 헤더 기반 진행률 계산 +- imapflow `meta.size`로 헤더 설정 +- totalSize fallback: `getUserMailAttachments`의 size 필드 사용 + +### 3. IMAP 연결 풀 +- 계정당 1개 연결 유지 (maxIdleMs: 5분) +- busy 상태 시 큐잉 처리 +- 연결 끊김 시 자동 재연결 + +### 4. 캐시 +- 메일 목록: 60초 TTL +- 메일 상세: 5분 TTL +- 읽음/삭제/이동 시 해당 캐시 무효화 + +### 5. POP3 미구현 +- `node-pop3` 패키지 설치됨 +- 서비스 파일 미작성 +- 팀장 지시 후 구현 예정 + +--- + +## 변경 이력 + +| 날짜 | 버전 | 내용 | +|------|------|------| +| 2026-03-27 | v1.0 | 초안 작성 | +| 2026-03-30 | v2.0 | IMAP 전 기능 구현 완료 (메일 조회/삭제/이동/첨부/발송/답장/전달/폴더/진행바) | + +--- + +## 관련 문서 + +- [UML[계획]-user-mail.md](./UML[계획]-user-mail.md): 아키텍처 및 설계 diff --git a/docs/ycshin-node/탭_시스템_설계.md b/docs/yc/탭_시스템_설계.md similarity index 100% rename from docs/ycshin-node/탭_시스템_설계.md rename to docs/yc/탭_시스템_설계.md diff --git a/docs/ycshin-node/필수입력항목_자동검증_설계.md b/docs/yc/필수입력항목_자동검증_설계.md similarity index 100% rename from docs/ycshin-node/필수입력항목_자동검증_설계.md rename to docs/yc/필수입력항목_자동검증_설계.md diff --git a/frontend/.gitignore b/frontend/.gitignore index 5ef6a520..21b00950 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -39,3 +39,4 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +.omc/ diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl deleted file mode 100644 index eeffca86..00000000 --- a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl +++ /dev/null @@ -1,14 +0,0 @@ -{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167} -{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548} -{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997} -{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528} -{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641} -{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980} -{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646} diff --git a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl deleted file mode 100644 index 64204160..00000000 --- a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735} -{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607} -{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249} -{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624} -{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"} -{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json deleted file mode 100644 index 9b6eaa2a..00000000 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "lastSentAt": "2026-03-25T05:06:13.529Z" -} \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json deleted file mode 100644 index cc6d2569..00000000 --- a/frontend/.omc/state/last-tool-error.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "tool_name": "Bash", - "tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}", - "error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx", - "timestamp": "2026-03-25T05:00:38.410Z", - "retry_count": 1 -} \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json deleted file mode 100644 index a46a9962..00000000 --- a/frontend/.omc/state/mission-state.json +++ /dev/null @@ -1,281 +0,0 @@ -{ - "updatedAt": "2026-03-25T05:06:35.487Z", - "missions": [ - { - "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-25T00:33:45.197Z", - "updatedAt": "2026-03-25T01:37:19.659Z", - "status": "done", - "workerCount": 5, - "taskCounts": { - "total": 5, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 5, - "failed": 0 - }, - "agents": [ - { - "name": "Explore:ad233db", - "role": "Explore", - "ownership": "ad233db7fa6f059dd", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:34:44.932Z" - }, - { - "name": "Explore:a31a0f7", - "role": "Explore", - "ownership": "a31a0f729d328643f", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:35:24.588Z" - }, - { - "name": "executor:a9510b7", - "role": "executor", - "ownership": "a9510b7d8ec5a1ce7", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:42:01.730Z" - }, - { - "name": "executor:a1c1d18", - "role": "executor", - "ownership": "a1c1d186f0eb6dfc1", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T00:40:12.608Z" - }, - { - "name": "executor:a9a231d", - "role": "executor", - "ownership": "a9a231d40fd5a150b", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T01:37:19.659Z" - } - ], - "timeline": [ - { - "id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z", - "at": "2026-03-25T00:40:12.608Z", - "kind": "completion", - "agent": "executor:a1c1d18", - "detail": "completed", - "sourceKey": "session-stop:a1c1d186f0eb6dfc1" - }, - { - "id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z", - "at": "2026-03-25T00:42:01.730Z", - "kind": "completion", - "agent": "executor:a9510b7", - "detail": "completed", - "sourceKey": "session-stop:a9510b7d8ec5a1ce7" - }, - { - "id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z", - "at": "2026-03-25T01:35:00.232Z", - "kind": "update", - "agent": "executor:a9a231d", - "detail": "started executor:a9a231d", - "sourceKey": "session-start:a9a231d40fd5a150b" - }, - { - "id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z", - "at": "2026-03-25T01:37:19.659Z", - "kind": "completion", - "agent": "executor:a9a231d", - "detail": "completed", - "sourceKey": "session-stop:a9a231d40fd5a150b" - } - ] - }, - { - "id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none", - "source": "session", - "name": "none", - "objective": "Session mission", - "createdAt": "2026-03-25T04:59:24.101Z", - "updatedAt": "2026-03-25T05:06:35.487Z", - "status": "done", - "workerCount": 7, - "taskCounts": { - "total": 7, - "pending": 0, - "blocked": 0, - "inProgress": 0, - "completed": 7, - "failed": 0 - }, - "agents": [ - { - "name": "executor:a32b34c", - "role": "executor", - "ownership": "a32b34c341b854da5", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:18.081Z" - }, - { - "name": "executor:ad2c89c", - "role": "executor", - "ownership": "ad2c89cf14936ea42", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:02:45.524Z" - }, - { - "name": "executor:a2c140c", - "role": "executor", - "ownership": "a2c140c5a5adb0719", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:05:13.388Z" - }, - { - "name": "executor:a2e5213", - "role": "executor", - "ownership": "a2e52136ea8f04385", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:03:53.163Z" - }, - { - "name": "executor:a3735bf", - "role": "executor", - "ownership": "a3735bf51a74d6fc8", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:01:33.817Z" - }, - { - "name": "executor:a77742b", - "role": "executor", - "ownership": "a77742ba65fd2451c", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:09.324Z" - }, - { - "name": "executor:a4eb932", - "role": "executor", - "ownership": "a4eb932c438b898c0", - "status": "done", - "currentStep": null, - "latestUpdate": "completed", - "completedSummary": null, - "updatedAt": "2026-03-25T05:06:35.487Z" - } - ], - "timeline": [ - { - "id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z", - "at": "2026-03-25T04:59:43.650Z", - "kind": "update", - "agent": "executor:a3735bf", - "detail": "started executor:a3735bf", - "sourceKey": "session-start:a3735bf51a74d6fc8" - }, - { - "id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z", - "at": "2026-03-25T04:59:48.683Z", - "kind": "update", - "agent": "executor:a77742b", - "detail": "started executor:a77742b", - "sourceKey": "session-start:a77742ba65fd2451c" - }, - { - "id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z", - "at": "2026-03-25T04:59:53.841Z", - "kind": "update", - "agent": "executor:a4eb932", - "detail": "started executor:a4eb932", - "sourceKey": "session-start:a4eb932c438b898c0" - }, - { - "id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z", - "at": "2026-03-25T05:01:33.817Z", - "kind": "completion", - "agent": "executor:a3735bf", - "detail": "completed", - "sourceKey": "session-stop:a3735bf51a74d6fc8" - }, - { - "id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z", - "at": "2026-03-25T05:02:45.524Z", - "kind": "completion", - "agent": "executor:ad2c89c", - "detail": "completed", - "sourceKey": "session-stop:ad2c89cf14936ea42" - }, - { - "id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z", - "at": "2026-03-25T05:03:53.163Z", - "kind": "completion", - "agent": "executor:a2e5213", - "detail": "completed", - "sourceKey": "session-stop:a2e52136ea8f04385" - }, - { - "id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z", - "at": "2026-03-25T05:05:13.388Z", - "kind": "completion", - "agent": "executor:a2c140c", - "detail": "completed", - "sourceKey": "session-stop:a2c140c5a5adb0719" - }, - { - "id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z", - "at": "2026-03-25T05:06:09.324Z", - "kind": "completion", - "agent": "executor:a77742b", - "detail": "completed", - "sourceKey": "session-stop:a77742ba65fd2451c" - }, - { - "id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z", - "at": "2026-03-25T05:06:18.081Z", - "kind": "completion", - "agent": "executor:a32b34c", - "detail": "completed", - "sourceKey": "session-stop:a32b34c341b854da5" - }, - { - "id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z", - "at": "2026-03-25T05:06:35.487Z", - "kind": "completion", - "agent": "executor:a4eb932", - "detail": "completed", - "sourceKey": "session-stop:a4eb932c438b898c0" - } - ] - } - ] -} \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json deleted file mode 100644 index 355a60d1..00000000 --- a/frontend/.omc/state/subagent-tracking.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "agents": [ - { - "agent_id": "ad233db7fa6f059dd", - "agent_type": "Explore", - "started_at": "2026-03-25T00:33:45.197Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:34:44.932Z", - "duration_ms": 59735 - }, - { - "agent_id": "a31a0f729d328643f", - "agent_type": "Explore", - "started_at": "2026-03-25T00:33:50.981Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:35:24.588Z", - "duration_ms": 93607 - }, - { - "agent_id": "a9510b7d8ec5a1ce7", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T00:37:40.106Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:42:01.730Z", - "duration_ms": 261624 - }, - { - "agent_id": "a1c1d186f0eb6dfc1", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T00:37:56.359Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T00:40:12.608Z", - "duration_ms": 136249 - }, - { - "agent_id": "a9a231d40fd5a150b", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T01:35:00.232Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T01:37:19.659Z", - "duration_ms": 139427 - }, - { - "agent_id": "a32b34c341b854da5", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:24.101Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:18.081Z", - "duration_ms": 413980 - }, - { - "agent_id": "ad2c89cf14936ea42", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:28.976Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:02:45.524Z", - "duration_ms": 196548 - }, - { - "agent_id": "a2c140c5a5adb0719", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:33.860Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:05:13.388Z", - "duration_ms": 339528 - }, - { - "agent_id": "a2e52136ea8f04385", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:39.166Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:03:53.163Z", - "duration_ms": 253997 - }, - { - "agent_id": "a3735bf51a74d6fc8", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:43.650Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:01:33.817Z", - "duration_ms": 110167 - }, - { - "agent_id": "a77742ba65fd2451c", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:48.683Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:09.324Z", - "duration_ms": 380641 - }, - { - "agent_id": "a4eb932c438b898c0", - "agent_type": "oh-my-claudecode:executor", - "started_at": "2026-03-25T04:59:53.841Z", - "parent_mode": "none", - "status": "completed", - "completed_at": "2026-03-25T05:06:35.487Z", - "duration_ms": 401646 - } - ], - "total_spawned": 12, - "total_completed": 12, - "total_failed": 0, - "last_updated": "2026-03-25T05:06:35.589Z" -} \ No newline at end of file diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index e4144506..29273b0b 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -1,14 +1,21 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { MenuProvider } from "@/contexts/MenuContext"; +import { MessengerProvider } from "@/contexts/MessengerContext"; import { AppLayout } from "@/components/layout/AppLayout"; import { ApprovalGlobalListener } from "@/components/approval/ApprovalGlobalListener"; +import { MessengerFAB } from "@/components/messenger/MessengerFAB"; +import { MessengerModal } from "@/components/messenger/MessengerModal"; export default function MainLayout({ children }: { children: React.ReactNode }) { return ( - {children} - + + {children} + + + + ); diff --git a/frontend/app/(main)/mail/imap/ComposeDialog.tsx b/frontend/app/(main)/mail/imap/ComposeDialog.tsx new file mode 100644 index 00000000..296d7960 --- /dev/null +++ b/frontend/app/(main)/mail/imap/ComposeDialog.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useEffect, useState } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { sendUserMail, UserMailAccount } 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; + accounts: UserMailAccount[]; +} + +export default function ComposeDialog({ + open, onOpenChange, mode, + to, setTo, cc, setCc, + subject, setSubject, + initialHtml, setInitialHtml, + inReplyTo, references, + sending, setSending, + accountId, accounts, +}: ComposeDialogProps) { + const [fromAccountId, setFromAccountId] = useState(accountId); + + useEffect(() => { + setFromAccountId(accountId ?? (accounts[0]?.id ?? null)); + }, [accountId, accounts, open]); + + 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 (!fromAccountId || !editor) return; + setSending(true); + try { + const html = editor.getHTML(); + const result = await sendUserMail(fromAccountId, { + to, + cc: cc || undefined, + subject, + html, + inReplyTo: inReplyTo || undefined, + references: references || undefined, + }); + if (result.success) { + onOpenChange(false); + setTo(""); setCc(""); setSubject(""); setInitialHtml(""); + } else { + toast.error(result.message); + } + } finally { + setSending(false); + } + } + + return ( + + + + + {mode === "reply" ? "답장" : mode === "forward" ? "전달" : "새 메일"} + + +
+
+ + +
+
+ + setTo(e.target.value)} placeholder="to@example.com" /> +
+
+ + setCc(e.target.value)} placeholder="cc@example.com (선택)" /> +
+
+ + setSubject(e.target.value)} /> +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/app/(main)/mail/imap/page.tsx b/frontend/app/(main)/mail/imap/page.tsx new file mode 100644 index 00000000..ccdeb317 --- /dev/null +++ b/frontend/app/(main)/mail/imap/page.tsx @@ -0,0 +1,1105 @@ +"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 { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { toast } from "sonner"; +import { Switch } from "@/components/ui/switch"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { ChevronsUpDown, Check } from "lucide-react"; +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, + Eye, + EyeOff, +} 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"; +import type ComposeDialogType from "./ComposeDialog"; +const ComposeDialogDynamic = dynamic(() => import("./ComposeDialog"), { ssr: false }) as typeof ComposeDialogType; + +const IMAP_PRESETS = [ + { label: "직접 입력", value: "custom", host: "", port: 993, useTls: true }, + { label: "Gmail", value: "gmail", host: "imap.gmail.com", port: 993, useTls: true }, + { label: "Naver", value: "naver", host: "imap.naver.com", port: 993, useTls: true }, + { label: "Outlook / Hotmail", value: "outlook", host: "outlook.office365.com", port: 993, useTls: true }, + { label: "Kakao", value: "kakao", host: "imap.kakao.com", port: 993, useTls: true }, + { label: "Daum", value: "daum", host: "imap.daum.net", port: 993, useTls: true }, +]; + +const DEFAULT_FORM: CreateUserMailAccountDto = { + displayName: "", + email: "", + protocol: "imap", + host: "", + port: 993, + useTls: true, + username: "", + password: "", +}; + +export default function ImapMailPage() { + const [accounts, setAccounts] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(null); + const [mailsMap, setMailsMap] = useState>(new Map()); + const [loadingMap, setLoadingMap] = useState>(new Map()); + const [minSeqnoMap, setMinSeqnoMap] = useState>(new Map()); + const [loadingMoreMap, setLoadingMoreMap] = useState>(new Map()); + const [selectedMail, setSelectedMail] = useState(null); + const [downloadProgress, setDownloadProgress] = useState>({}); + const [loadingAccounts, setLoadingAccounts] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [showDialog, setShowDialog] = useState(false); + const [editingAccount, setEditingAccount] = useState(null); + const [form, setForm] = useState(DEFAULT_FORM); + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); + const [testing, setTesting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [formErrors, setFormErrors] = useState>>({}); + + // New states + const [folders, setFolders] = useState([]); + const [currentFolder, setCurrentFolder] = useState("INBOX"); + const [composeOpen, setComposeOpen] = useState(false); + const [composeMode, setComposeMode] = useState<"new" | "reply" | "forward">("new"); + const [composeTo, setComposeTo] = useState(""); + const [composeCc, setComposeCc] = useState(""); + const [composeSubject, setComposeSubject] = useState(""); + const [composeInitialHtml, setComposeInitialHtml] = useState(""); + const [composeInReplyTo, setComposeInReplyTo] = useState(""); + const [composeReferences, setComposeReferences] = useState(""); + const [composeSending, setComposeSending] = useState(false); + const [pendingDeleteMail, setPendingDeleteMail] = useState(null); + const [pendingDeleteAccount, setPendingDeleteAccount] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [hostPopoverOpen, setHostPopoverOpen] = useState(false); + + const detailCacheRef = useRef>(new Map()); + const prefetchingRef = useRef>(new Set()); + const mailsMapRef = useRef>(new Map()); + const hoverTimerRef = useRef(null); + + // 현재 선택 계정 기준 파생값 + const mails = selectedAccount ? (mailsMap.get(selectedAccount.id) || []) : []; + const loadingMails = selectedAccount ? (loadingMap.get(selectedAccount.id) ?? false) : false; + const minSeqno = selectedAccount ? (minSeqnoMap.get(selectedAccount.id) ?? null) : null; + const loadingMore = selectedAccount ? (loadingMoreMap.get(selectedAccount.id) ?? false) : false; + + const imapAccounts = accounts.filter((a) => a.protocol === "imap"); + + useEffect(() => { + loadAccounts(); + }, []); + + useEffect(() => { + if (selectedAccount) { + setSelectedMail(null); + setCurrentFolder("INBOX"); + loadFolders(selectedAccount); + // 아직 로딩 안 됐으면 시작 + if (!mailsMap.has(selectedAccount.id) && !loadingMap.get(selectedAccount.id)) { + startStream(selectedAccount); + } + } + }, [selectedAccount]); + + async function loadAccounts() { + setLoadingAccounts(true); + try { + const data = await getUserMailAccounts(); + setAccounts(data); + // 모든 계정 동시 프리로드 + const imapAccts = data.filter((a: UserMailAccount) => a.protocol === "imap"); + for (const account of imapAccts) { + startStream(account); + } + } catch (e) { + console.error("계정 목록 로드 실패:", e); + } finally { + setLoadingAccounts(false); + } + } + + async function loadFolders(account: UserMailAccount) { + try { + const data = await getUserMailFolders(account.id); + setFolders(data); + } catch { + setFolders([]); + } + } + + async function prefetchDetail(account: UserMailAccount, mail: ReceivedMail) { + if (detailCacheRef.current.has(mail.id)) return; + if (prefetchingRef.current.has(mail.id)) return; + prefetchingRef.current.add(mail.id); + try { + const seqno = parseInt(mail.id.split("-").pop() || "0"); + if (!seqno) return; + const detail = await getUserMailDetail(account.id, seqno); + if (detail) detailCacheRef.current.set(mail.id, detail); + } catch { + // 프리로드 실패 무시 + } finally { + prefetchingRef.current.delete(mail.id); + } + } + + function startStream(account: UserMailAccount, before: number | null = null, append = false) { + // 이미 로딩 중이면 스킵 (초기 로드 한정) + if (!append && loadingMap.get(account.id)) return; + + setLoadingMap((prev) => new Map(prev).set(account.id, true)); + + const cancel = streamUserMails( + account.id, 20, before, + (mail) => { + setMailsMap((prev) => { + const next = new Map(prev); + const existing = next.get(account.id) || []; + const updated = append + ? [...existing, mail] + : [mail, ...existing.filter((m) => m.id !== mail.id)]; + next.set(account.id, updated); + mailsMapRef.current = next; + return next; + }); + setMinSeqnoMap((prev) => { + const seqno = parseInt(mail.id.split("-").pop() || "0"); + const current = prev.get(account.id) ?? null; + return new Map(prev).set(account.id, current === null ? seqno : Math.min(current, seqno)); + }); + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + }, + () => { + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + setLoadingMoreMap((prev) => new Map(prev).set(account.id, false)); + // 상위 5개 자동 프리로드 (순차, Gmail은 throttling으로 느려지므로 간격 늘림) + const currentMails = mailsMapRef.current.get(account.id) || []; + const top5 = currentMails.slice(0, 5); + const isGmail = account.host.toLowerCase().includes('gmail') || account.email.toLowerCase().includes('@gmail'); + const interval = isGmail ? 800 : 100; + top5.forEach((m, i) => { + setTimeout(() => prefetchDetail(account, m), i * interval); + }); + }, + (err) => { + setLoadingMap((prev) => new Map(prev).set(account.id, false)); + setLoadingMoreMap((prev) => new Map(prev).set(account.id, false)); + console.error("스트리밍 오류:", err); + } + ); + return cancel; + } + + function handleFolderClick(folder: string) { + if (!selectedAccount) return; + setCurrentFolder(folder); + setSelectedMail(null); + + if (folder === "INBOX") { + setMailsMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; }); + setMinSeqnoMap((prev) => { const n = new Map(prev); n.delete(selectedAccount.id); return n; }); + startStream(selectedAccount); + } else { + setMailsMap((prev) => { const n = new Map(prev); n.set(selectedAccount.id, []); return n; }); + setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, true)); + streamFolderMails( + selectedAccount.id, folder, 20, null, + (mail) => { + setMailsMap((prev) => { + const n = new Map(prev); + n.set(selectedAccount.id, [...(n.get(selectedAccount.id) || []), mail]); + return n; + }); + setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); + }, + () => setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)), + (err) => { setLoadingMap((prev) => new Map(prev).set(selectedAccount.id, false)); console.error(err); } + ); + } + } + + async function handleMailClick(mail: ReceivedMail) { + if (!selectedAccount) return; + const seqno = parseInt(mail.id.split("-").pop() || "0"); + + // 캐시 히트 시 즉시 표시 + const cached = detailCacheRef.current.get(mail.id); + if (cached) { + setSelectedMail(cached); + // 첨부파일 로드 + if (seqno) { + } + if (!mail.isRead) { + markUserMailAsRead(selectedAccount.id, seqno).then(() => loadFolders(selectedAccount)).catch(() => {}); + setMailsMap((prev) => { + const next = new Map(prev); + const mails = (next.get(selectedAccount.id) || []).map((m) => + m.id === mail.id ? { ...m, isRead: true } : m + ); + next.set(selectedAccount.id, mails); + mailsMapRef.current = next; + return next; + }); + } + return; + } + + // 캐시 미스 시 fetch + setLoadingDetail(true); + try { + const detail = await getUserMailDetail(selectedAccount.id, seqno); + if (detail) { + detailCacheRef.current.set(mail.id, detail); + setSelectedMail(detail); + } + // 첨부파일 로드 + if (seqno) { + } + if (!mail.isRead) { + await markUserMailAsRead(selectedAccount.id, seqno); + setMailsMap((prev) => { + const next = new Map(prev); + const mails = (next.get(selectedAccount.id) || []).map((m) => + m.id === mail.id ? { ...m, isRead: true } : m + ); + next.set(selectedAccount.id, mails); + mailsMapRef.current = next; + return next; + }); + loadFolders(selectedAccount); + } + } catch (e) { + console.error("메일 상세 로드 실패:", e); + } finally { + setLoadingDetail(false); + } + } + + async function handleDeleteMail(mail: ReceivedMail) { + if (!selectedAccount) return; + setPendingDeleteMail(mail); + } + + async function confirmDeleteMail() { + if (!selectedAccount || !pendingDeleteMail) return; + const mail = pendingDeleteMail; + setPendingDeleteMail(null); + 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) { + toast.error("메일 삭제 실패: " + e.message); + } + } + + async function handleMove(mail: ReceivedMail, targetFolder: string) { + if (!selectedAccount) return; + const seqno = parseInt(mail.id.split("-").pop() || "0"); + try { + await moveUserMail(selectedAccount.id, seqno, targetFolder); + setMailsMap((prev) => { + const next = new Map(prev); + next.set(selectedAccount.id, (next.get(selectedAccount.id) || []).filter((m) => m.id !== mail.id)); + return next; + }); + if (selectedMail?.id === mail.id) setSelectedMail(null); + } catch (e) { + console.error("메일 이동 실패:", e); + } + } + + function handleReply() { + if (!selectedMail) return; + setComposeMode("reply"); + setComposeTo(selectedMail.from); + setComposeSubject(`Re: ${selectedMail.subject.replace(/^Re:\s*/i, "")}`); + setComposeInReplyTo(selectedMail.messageId); + setComposeReferences(selectedMail.messageId); + const dateStr = new Date(selectedMail.date).toLocaleString("ko-KR"); + setComposeInitialHtml( + `

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

---------- 전달된 메일 ----------
+
보낸사람: ${selectedMail.from}
+
날짜: ${new Date(selectedMail.date).toLocaleString("ko-KR")}
+
제목: ${selectedMail.subject}
+
받는사람: ${selectedMail.to}
+
${selectedMail.htmlBody || selectedMail.textBody}` + ); + setComposeOpen(true); + } + + function openAddDialog() { + setEditingAccount(null); + setForm(DEFAULT_FORM); + setTestResult(null); + setShowPassword(false); + setHostPopoverOpen(false); + setFormErrors({}); + 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); + setShowPassword(false); + setShowDialog(true); + } + + function handleTlsToggle(checked: boolean) { + setForm((prev) => ({ + ...prev, + useTls: checked, + port: checked ? 993 : 143, + })); + } + + async function handleSave() { + const errors: typeof formErrors = {}; + if (!form.displayName.trim()) errors.displayName = "표시 이름을 입력하세요."; + if (!form.email.trim()) errors.email = "이메일 주소를 입력하세요."; + if (!form.host.trim()) errors.host = "IMAP 호스트를 입력하세요."; + if (!form.username.trim()) errors.username = "사용자명을 입력하세요."; + if (!editingAccount && !form.password.trim()) errors.password = "비밀번호를 입력하세요."; + if (Object.keys(errors).length > 0) { setFormErrors(errors); return; } + setFormErrors({}); + 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) { + setPendingDeleteAccount(account); + } + + async function confirmDeleteAccount() { + if (!pendingDeleteAccount) return; + const account = pendingDeleteAccount; + setPendingDeleteAccount(null); + 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 ( +
+ {/* 헤더 */} +
+
+ +

메일 관리

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

계정을 선택하세요

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

{searchTerm ? `"${searchTerm}" 검색 결과가 없습니다` : "메일이 없습니다"}

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

메일을 선택하세요

+
+ ) : ( + <> +
+

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

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