메일 관리 작업 저장용 커밋

This commit is contained in:
leeheejin
2025-10-01 16:15:53 +09:00
parent 2a8841c6dc
commit 0209be8fd6
65 changed files with 8636 additions and 2145 deletions

View File

@@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.16.2",
"@types/imap": "^0.8.42",
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
@@ -19,13 +20,15 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.4",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
@@ -43,7 +46,7 @@
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.20",
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5",
@@ -2398,6 +2401,19 @@
"@redis/client": "^1.0.0"
}
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"selderee": "^0.11.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -3277,6 +3293,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/imap": {
"version": "0.8.42",
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -3412,9 +3437,9 @@
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "6.4.19",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz",
"integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==",
"version": "6.4.20",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
"integrity": "sha512-uj83z0GqwqMUE6RI4EKptPlav0FYE6vpIlqJAnxzu+/sSezRdbH69rSBCMsdW6DdsCAzoFQZ52c2UIlhRVQYDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5040,7 +5065,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5218,7 +5242,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
@@ -5233,7 +5256,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -5246,7 +5268,6 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
@@ -5262,7 +5283,6 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
@@ -5377,11 +5397,19 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -6463,6 +6491,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
@@ -6479,11 +6516,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "^0.11.0",
"deepmerge": "^4.3.1",
"dom-serializer": "^2.0.0",
"htmlparser2": "^8.0.2",
"selderee": "^0.11.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
@@ -6600,6 +6652,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/imap": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
"dependencies": {
"readable-stream": "1.1.x",
"utf7": ">=1.0.2"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/imap/node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
"license": "MIT"
},
"node_modules/imap/node_modules/readable-stream": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/imap/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -7678,6 +7766,15 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7702,6 +7799,42 @@
"node": ">= 0.8.0"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -7709,6 +7842,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -7829,6 +7971,56 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/mailparser": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.6.3",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"mailsplit": "5.4.5",
"nodemailer": "7.0.4",
"punycode.js": "2.3.1",
"tlds": "1.259.0"
}
},
"node_modules/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/mailsplit": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -8511,6 +8703,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parseley": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
"license": "MIT",
"dependencies": {
"leac": "^0.6.0",
"peberminta": "^0.9.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -8580,6 +8785,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -8971,6 +9185,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -9296,6 +9519,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
"license": "MIT",
"dependencies": {
"parseley": "^0.12.0"
},
"funding": {
"url": "https://ko-fi.com/killymxi"
}
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -9904,6 +10139,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.259.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -10150,6 +10394,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -10227,6 +10477,23 @@
"punycode": "^2.1.0"
}
},
"node_modules/utf7": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
"dependencies": {
"semver": "~5.3.0"
}
},
"node_modules/utf7/node_modules/semver": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.16.2",
"@types/imap": "^0.8.42",
"@types/mssql": "^9.1.8",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
@@ -37,13 +38,15 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.4",
"mssql": "^11.0.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.15.0",
"node-cron": "^4.2.1",
"nodemailer": "^6.9.7",
"nodemailer": "^6.10.1",
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
@@ -61,7 +64,7 @@
"@types/multer": "^1.4.13",
"@types/node": "^20.10.5",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.14",
"@types/nodemailer": "^6.4.20",
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5",

View File

@@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
import templateStandardRoutes from "./routes/templateStandardRoutes";
import componentStandardRoutes from "./routes/componentStandardRoutes";
import layoutRoutes from "./routes/layoutRoutes";
import mailQueryRoutes from "./routes/mailQueryRoutes";
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
@@ -156,6 +160,10 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
app.use("/api/admin/template-standards", templateStandardRoutes);
app.use("/api/admin/component-standards", componentStandardRoutes);
app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/query", mailQueryRoutes); // SQL 쿼리 빌더
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);

View File

@@ -0,0 +1,201 @@
import { Request, Response } from 'express';
import { mailAccountFileService } from '../services/mailAccountFileService';
export class MailAccountFileController {
async getAllAccounts(req: Request, res: Response) {
try {
const accounts = await mailAccountFileService.getAllAccounts();
// 비밀번호는 반환하지 않음
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
return res.json({
success: true,
data: safeAccounts,
total: safeAccounts.length,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 조회 실패',
error: err.message,
});
}
}
async getAccountById(req: Request, res: Response) {
try {
const { id } = req.params;
const account = await mailAccountFileService.getAccountById(id);
if (!account) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
// 비밀번호는 마스킹 처리
const { smtpPassword, ...safeAccount } = account;
return res.json({
success: true,
data: {
...safeAccount,
smtpPassword: '••••••••', // 마스킹
},
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 조회 실패',
error: err.message,
});
}
}
async createAccount(req: Request, res: Response) {
try {
const {
name,
email,
smtpHost,
smtpPort,
smtpSecure,
smtpUsername,
smtpPassword,
dailyLimit,
status,
} = req.body;
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
return res.status(400).json({
success: false,
message: '필수 필드가 누락되었습니다.',
});
}
// 이메일 중복 확인
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
if (existingAccount) {
return res.status(400).json({
success: false,
message: '이미 등록된 이메일입니다.',
});
}
const account = await mailAccountFileService.createAccount({
name,
email,
smtpHost,
smtpPort,
smtpSecure: smtpSecure || false,
smtpUsername,
smtpPassword,
dailyLimit: dailyLimit || 1000,
status: status || 'active',
});
// 비밀번호 제외하고 반환
const { smtpPassword: _, ...safeAccount } = account;
return res.status(201).json({
success: true,
data: safeAccount,
message: '메일 계정이 생성되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 생성 실패',
error: err.message,
});
}
}
async updateAccount(req: Request, res: Response) {
try {
const { id } = req.params;
const updates = req.body;
const account = await mailAccountFileService.updateAccount(id, updates);
if (!account) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
// 비밀번호 제외하고 반환
const { smtpPassword: _, ...safeAccount } = account;
return res.json({
success: true,
data: safeAccount,
message: '계정이 수정되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 수정 실패',
error: err.message,
});
}
}
async deleteAccount(req: Request, res: Response) {
try {
const { id } = req.params;
const success = await mailAccountFileService.deleteAccount(id);
if (!success) {
return res.status(404).json({
success: false,
message: '계정을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
message: '계정이 삭제되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '계정 삭제 실패',
error: err.message,
});
}
}
async testConnection(req: Request, res: Response) {
try {
const { id } = req.params;
// TODO: 실제 SMTP 연결 테스트 구현
// const account = await mailAccountFileService.getAccountById(id);
// nodemailer로 연결 테스트
return res.json({
success: true,
message: '연결 테스트 성공 (미구현)',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '연결 테스트 실패',
error: err.message,
});
}
}
}
export const mailAccountFileController = new MailAccountFileController();

View File

@@ -0,0 +1,213 @@
import { Request, Response } from 'express';
import { mailQueryService, QueryParameter } from '../services/mailQueryService';
export class MailQueryController {
// 쿼리에서 파라미터 감지
async detectParameters(req: Request, res: Response) {
try {
const { sql } = req.body;
if (!sql) {
return res.status(400).json({
success: false,
message: 'SQL 쿼리가 필요합니다.',
});
}
const parameters = mailQueryService.detectParameters(sql);
return res.json({
success: true,
data: parameters,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '파라미터 감지 실패',
error: err.message,
});
}
}
// 쿼리 테스트 실행
async testQuery(req: Request, res: Response) {
try {
const { sql, parameters } = req.body;
if (!sql) {
return res.status(400).json({
success: false,
message: 'SQL 쿼리가 필요합니다.',
});
}
const result = await mailQueryService.testQuery(
sql,
parameters || []
);
return res.json({
success: result.success,
data: result,
message: result.success
? '쿼리 테스트 성공'
: '쿼리 테스트 실패',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '쿼리 테스트 실패',
error: err.message,
});
}
}
// 쿼리 실행
async executeQuery(req: Request, res: Response) {
try {
const { sql, parameters } = req.body;
if (!sql) {
return res.status(400).json({
success: false,
message: 'SQL 쿼리가 필요합니다.',
});
}
const result = await mailQueryService.executeQuery(
sql,
parameters || []
);
return res.json({
success: result.success,
data: result,
message: result.success
? '쿼리 실행 성공'
: '쿼리 실행 실패',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '쿼리 실행 실패',
error: err.message,
});
}
}
// 템플릿 변수 추출
async extractVariables(req: Request, res: Response) {
try {
const { template } = req.body;
if (!template) {
return res.status(400).json({
success: false,
message: '템플릿이 필요합니다.',
});
}
const variables = mailQueryService.extractVariables(template);
return res.json({
success: true,
data: variables,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '변수 추출 실패',
error: err.message,
});
}
}
// 변수 매핑 검증
async validateMapping(req: Request, res: Response) {
try {
const { templateVariables, queryFields } = req.body;
if (!templateVariables || !queryFields) {
return res.status(400).json({
success: false,
message: '템플릿 변수와 쿼리 필드가 필요합니다.',
});
}
const validation = mailQueryService.validateVariableMapping(
templateVariables,
queryFields
);
return res.json({
success: true,
data: validation,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '변수 매핑 검증 실패',
error: err.message,
});
}
}
// 대량 메일 데이터 처리
async processMailData(req: Request, res: Response) {
try {
const { templateHtml, templateSubject, sql, parameters } = req.body;
if (!templateHtml || !templateSubject || !sql) {
return res.status(400).json({
success: false,
message: '템플릿, 제목, SQL 쿼리가 모두 필요합니다.',
});
}
// 쿼리 실행
const queryResult = await mailQueryService.executeQuery(
sql,
parameters || []
);
if (!queryResult.success) {
return res.status(400).json({
success: false,
message: '쿼리 실행 실패',
error: queryResult.error,
});
}
// 메일 데이터 처리
const mailData = await mailQueryService.processMailData(
templateHtml,
templateSubject,
queryResult
);
return res.json({
success: true,
data: {
totalRecipients: mailData.length,
mailData: mailData.slice(0, 5), // 미리보기용 5개만
},
message: `${mailData.length}명의 수신자에게 발송 준비 완료`,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '메일 데이터 처리 실패',
error: err.message,
});
}
}
}
export const mailQueryController = new MailQueryController();

View File

@@ -0,0 +1,96 @@
import { Request, Response } from 'express';
import { mailSendSimpleService } from '../services/mailSendSimpleService';
export class MailSendSimpleController {
/**
* 메일 발송 (단건 또는 소규모)
*/
async sendMail(req: Request, res: Response) {
try {
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
// 필수 파라미터 검증
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
return res.status(400).json({
success: false,
message: '계정 ID와 수신자 이메일이 필요합니다.',
});
}
if (!subject) {
return res.status(400).json({
success: false,
message: '메일 제목이 필요합니다.',
});
}
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
if (!templateId && !customHtml) {
return res.status(400).json({
success: false,
message: '템플릿 또는 메일 내용이 필요합니다.',
});
}
// 메일 발송
const result = await mailSendSimpleService.sendMail({
accountId,
templateId,
to,
subject,
variables,
customHtml,
});
if (result.success) {
return res.json({
success: true,
data: result,
message: '메일이 발송되었습니다.',
});
} else {
return res.status(500).json({
success: false,
message: result.error || '메일 발송 실패',
});
}
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '메일 발송 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
* SMTP 연결 테스트
*/
async testConnection(req: Request, res: Response) {
try {
const { accountId } = req.body;
if (!accountId) {
return res.status(400).json({
success: false,
message: '계정 ID가 필요합니다.',
});
}
const result = await mailSendSimpleService.testConnection(accountId);
return res.json(result);
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '연결 테스트 실패',
error: err.message,
});
}
}
}
export const mailSendSimpleController = new MailSendSimpleController();

View File

@@ -0,0 +1,258 @@
import { Request, Response } from 'express';
import { mailTemplateFileService } from '../services/mailTemplateFileService';
import { mailQueryService } from '../services/mailQueryService';
export class MailTemplateFileController {
// 모든 템플릿 조회
async getAllTemplates(req: Request, res: Response) {
try {
const { category, search } = req.query;
let templates;
if (search) {
templates = await mailTemplateFileService.searchTemplates(search as string);
} else if (category) {
templates = await mailTemplateFileService.getTemplatesByCategory(category as string);
} else {
templates = await mailTemplateFileService.getAllTemplates();
}
return res.json({
success: true,
data: templates,
total: templates.length,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '템플릿 조회 실패',
error: err.message,
});
}
}
// 특정 템플릿 조회
async getTemplateById(req: Request, res: Response) {
try {
const { id } = req.params;
const template = await mailTemplateFileService.getTemplateById(id);
if (!template) {
return res.status(404).json({
success: false,
message: '템플릿을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
data: template,
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '템플릿 조회 실패',
error: err.message,
});
}
}
// 템플릿 생성
async createTemplate(req: Request, res: Response) {
try {
const { name, subject, components, queryConfig, recipientConfig, category } = req.body;
if (!name || !subject || !Array.isArray(components)) {
return res.status(400).json({
success: false,
message: '템플릿 이름, 제목, 컴포넌트가 필요합니다.',
});
}
const template = await mailTemplateFileService.createTemplate({
name,
subject,
components,
queryConfig,
recipientConfig,
category,
});
return res.status(201).json({
success: true,
data: template,
message: '템플릿이 생성되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '템플릿 생성 실패',
error: err.message,
});
}
}
// 템플릿 수정
async updateTemplate(req: Request, res: Response) {
try {
const { id } = req.params;
const updates = req.body;
const template = await mailTemplateFileService.updateTemplate(id, updates);
if (!template) {
return res.status(404).json({
success: false,
message: '템플릿을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
data: template,
message: '템플릿이 수정되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '템플릿 수정 실패',
error: err.message,
});
}
}
// 템플릿 삭제
async deleteTemplate(req: Request, res: Response) {
try {
const { id } = req.params;
const success = await mailTemplateFileService.deleteTemplate(id);
if (!success) {
return res.status(404).json({
success: false,
message: '템플릿을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
message: '템플릿이 삭제되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '템플릿 삭제 실패',
error: err.message,
});
}
}
// 템플릿 미리보기 (HTML 렌더링)
async previewTemplate(req: Request, res: Response) {
try {
const { id } = req.params;
const { sampleData } = req.body;
const template = await mailTemplateFileService.getTemplateById(id);
if (!template) {
return res.status(404).json({
success: false,
message: '템플릿을 찾을 수 없습니다.',
});
}
// HTML 렌더링
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
let subject = template.subject;
// 샘플 데이터가 있으면 변수 치환
if (sampleData) {
html = mailQueryService.replaceVariables(html, sampleData);
subject = mailQueryService.replaceVariables(subject, sampleData);
}
return res.json({
success: true,
data: {
subject,
html,
sampleData,
},
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '미리보기 생성 실패',
error: err.message,
});
}
}
// 템플릿 + 쿼리 통합 미리보기
async previewWithQuery(req: Request, res: Response) {
try {
const { id } = req.params;
const { queryId, parameters } = req.body;
const template = await mailTemplateFileService.getTemplateById(id);
if (!template) {
return res.status(404).json({
success: false,
message: '템플릿을 찾을 수 없습니다.',
});
}
// 쿼리 실행
const query = template.queryConfig?.queries.find(q => q.id === queryId);
if (!query) {
return res.status(404).json({
success: false,
message: '쿼리를 찾을 수 없습니다.',
});
}
const queryResult = await mailQueryService.executeQuery(query.sql, parameters || []);
if (!queryResult.success || !queryResult.data || queryResult.data.length === 0) {
return res.status(400).json({
success: false,
message: '쿼리 결과가 없습니다.',
error: queryResult.error,
});
}
// 첫 번째 행으로 미리보기
const sampleData = queryResult.data[0];
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
let subject = template.subject;
html = mailQueryService.replaceVariables(html, sampleData);
subject = mailQueryService.replaceVariables(subject, sampleData);
return res.json({
success: true,
data: {
subject,
html,
sampleData,
totalRecipients: queryResult.data.length,
},
});
} catch (error: unknown) {
const err = error as Error;
return res.status(500).json({
success: false,
message: '쿼리 미리보기 실패',
error: err.message,
});
}
}
}
export const mailTemplateFileController = new MailTemplateFileController();

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { mailAccountFileController } from '../controllers/mailAccountFileController';
const router = Router();
router.get('/', (req, res) => mailAccountFileController.getAllAccounts(req, res));
router.get('/:id', (req, res) => mailAccountFileController.getAccountById(req, res));
router.post('/', (req, res) => mailAccountFileController.createAccount(req, res));
router.put('/:id', (req, res) => mailAccountFileController.updateAccount(req, res));
router.delete('/:id', (req, res) => mailAccountFileController.deleteAccount(req, res));
router.post('/:id/test-connection', (req, res) => mailAccountFileController.testConnection(req, res));
export default router;

View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import { mailQueryController } from '../controllers/mailQueryController';
const router = Router();
// 쿼리 파라미터 자동 감지
router.post('/detect-parameters', (req, res) =>
mailQueryController.detectParameters(req, res)
);
// 쿼리 테스트 실행
router.post('/test', (req, res) =>
mailQueryController.testQuery(req, res)
);
// 쿼리 실행
router.post('/execute', (req, res) =>
mailQueryController.executeQuery(req, res)
);
// 템플릿 변수 추출
router.post('/extract-variables', (req, res) =>
mailQueryController.extractVariables(req, res)
);
// 변수 매핑 검증
router.post('/validate-mapping', (req, res) =>
mailQueryController.validateMapping(req, res)
);
// 대량 메일 데이터 처리
router.post('/process-mail-data', (req, res) =>
mailQueryController.processMailData(req, res)
);
export default router;

View File

@@ -0,0 +1,13 @@
import { Router } from 'express';
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
const router = Router();
// POST /api/mail/send/simple - 메일 발송
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
// POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
export default router;

View File

@@ -0,0 +1,18 @@
import { Router } from 'express';
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
const router = Router();
// 템플릿 CRUD
router.get('/', (req, res) => mailTemplateFileController.getAllTemplates(req, res));
router.get('/:id', (req, res) => mailTemplateFileController.getTemplateById(req, res));
router.post('/', (req, res) => mailTemplateFileController.createTemplate(req, res));
router.put('/:id', (req, res) => mailTemplateFileController.updateTemplate(req, res));
router.delete('/:id', (req, res) => mailTemplateFileController.deleteTemplate(req, res));
// 미리보기
router.post('/:id/preview', (req, res) => mailTemplateFileController.previewTemplate(req, res));
router.post('/:id/preview-with-query', (req, res) => mailTemplateFileController.previewWithQuery(req, res));
export default router;

View File

@@ -0,0 +1,76 @@
import crypto from 'crypto';
class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
constructor() {
const keyString = process.env.ENCRYPTION_KEY;
if (!keyString) {
throw new Error('ENCRYPTION_KEY environment variable is required');
}
this.key = crypto.scryptSync(keyString, 'salt', 32);
}
encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(this.algorithm, this.key);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
decrypt(encryptedText: string): string {
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
if (!ivHex || !authTagHex || !encrypted) {
throw new Error('Invalid encrypted text format');
}
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipher(this.algorithm, this.key);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 비밀번호 해싱 (bcrypt 대신 사용)
hashPassword(password: string): string {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
return salt + ':' + hash;
}
verifyPassword(password: string, hashedPassword: string): boolean {
const [salt, hash] = hashedPassword.split(':');
const verifyHash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
return hash === verifyHash;
}
// 랜덤 토큰 생성
generateToken(length: number = 32): string {
return crypto.randomBytes(length).toString('hex');
}
// HMAC 서명 생성
createHmac(data: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(data).digest('hex');
}
// HMAC 검증
verifyHmac(data: string, signature: string, secret: string): boolean {
const expectedSignature = this.createHmac(data, secret);
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
}
}
export const encryptionService = new EncryptionService();

View File

@@ -0,0 +1,159 @@
import fs from 'fs/promises';
import path from 'path';
import { encryptionService } from './encryptionService';
export interface MailAccount {
id: string;
name: string;
email: string;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUsername: string;
smtpPassword: string; // 암호화된 비밀번호
dailyLimit: number;
status: 'active' | 'inactive' | 'suspended';
createdAt: string;
updatedAt: string;
}
class MailAccountFileService {
private accountsDir: string;
constructor() {
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
this.ensureDirectoryExists();
}
private async ensureDirectoryExists() {
try {
await fs.access(this.accountsDir);
} catch {
await fs.mkdir(this.accountsDir, { recursive: true });
}
}
private getAccountPath(id: string): string {
return path.join(this.accountsDir, `${id}.json`);
}
async getAllAccounts(): Promise<MailAccount[]> {
await this.ensureDirectoryExists();
try {
const files = await fs.readdir(this.accountsDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
const accounts = await Promise.all(
jsonFiles.map(async (file) => {
const content = await fs.readFile(
path.join(this.accountsDir, file),
'utf-8'
);
return JSON.parse(content) as MailAccount;
})
);
return accounts.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
} catch {
return [];
}
}
async getAccountById(id: string): Promise<MailAccount | null> {
try {
const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
async createAccount(
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'>
): Promise<MailAccount> {
const id = `account-${Date.now()}`;
const now = new Date().toISOString();
// 비밀번호 암호화
const encryptedPassword = encryptionService.encrypt(data.smtpPassword);
const account: MailAccount = {
...data,
id,
smtpPassword: encryptedPassword,
createdAt: now,
updatedAt: now,
};
await fs.writeFile(
this.getAccountPath(id),
JSON.stringify(account, null, 2),
'utf-8'
);
return account;
}
async updateAccount(
id: string,
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>>
): Promise<MailAccount | null> {
const existing = await this.getAccountById(id);
if (!existing) {
return null;
}
// 비밀번호가 변경되면 암호화
if (data.smtpPassword && data.smtpPassword !== existing.smtpPassword) {
data.smtpPassword = encryptionService.encrypt(data.smtpPassword);
}
const updated: MailAccount = {
...existing,
...data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await fs.writeFile(
this.getAccountPath(id),
JSON.stringify(updated, null, 2),
'utf-8'
);
return updated;
}
async deleteAccount(id: string): Promise<boolean> {
try {
await fs.unlink(this.getAccountPath(id));
return true;
} catch {
return false;
}
}
async getAccountByEmail(email: string): Promise<MailAccount | null> {
const accounts = await this.getAllAccounts();
return accounts.find(a => a.email === email) || null;
}
async getActiveAccounts(): Promise<MailAccount[]> {
const accounts = await this.getAllAccounts();
return accounts.filter(a => a.status === 'active');
}
/**
* 비밀번호 복호화
*/
decryptPassword(encryptedPassword: string): string {
return encryptionService.decrypt(encryptedPassword);
}
}
export const mailAccountFileService = new MailAccountFileService();

View File

@@ -0,0 +1,241 @@
import { query } from '../database/db';
export interface QueryParameter {
name: string; // $1, $2, etc.
type: 'text' | 'number' | 'date';
value?: any;
}
export interface QueryResult {
success: boolean;
data?: any[];
fields?: string[];
error?: string;
}
export interface QueryConfig {
id: string;
name: string;
sql: string;
parameters: QueryParameter[];
}
export interface MailQueryConfig extends QueryConfig {}
export interface MailComponent {
id: string;
type: "text" | "button" | "image" | "spacer" | "table";
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
styles?: Record<string, string>;
}
class MailQueryService {
/**
* 쿼리에서 파라미터 자동 감지 ($1, $2, ...)
*/
detectParameters(sql: string): QueryParameter[] {
const regex = /\$(\d+)/g;
const matches = Array.from(sql.matchAll(regex));
const uniqueParams = new Set(matches.map(m => m[1]));
return Array.from(uniqueParams)
.sort((a, b) => parseInt(a) - parseInt(b))
.map(num => ({
name: `$${num}`,
type: 'text',
}));
}
/**
* 쿼리 실행 및 결과 반환
*/
async executeQuery(
sql: string,
parameters: QueryParameter[]
): Promise<QueryResult> {
try {
// 파라미터 값을 배열로 변환
const paramValues = parameters
.sort((a, b) => {
const aNum = parseInt(a.name.substring(1));
const bNum = parseInt(b.name.substring(1));
return aNum - bNum;
})
.map(p => {
if (p.type === 'number') {
return parseFloat(p.value);
} else if (p.type === 'date') {
return new Date(p.value);
}
return p.value;
});
// 쿼리 실행
const rows = await query(sql, paramValues);
// 결과에서 필드명 추출
const fields = rows.length > 0 ? Object.keys(rows[0]) : [];
return {
success: true,
data: rows,
fields,
};
} catch (error: unknown) {
const err = error as Error;
return {
success: false,
error: err.message,
};
}
}
/**
* 쿼리 결과에서 이메일 필드 자동 감지
*/
detectEmailFields(fields: string[]): string[] {
const emailPattern = /email|mail|e_mail/i;
return fields.filter(field => emailPattern.test(field));
}
/**
* 동적 변수 치환
* 예: "{customer_name}" → "홍길동"
*/
replaceVariables(template: string, data: Record<string, any>): string {
let result = template;
Object.keys(data).forEach(key => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, String(data[key] || ''));
});
return result;
}
/**
* 템플릿에서 사용된 변수 추출
* 예: "Hello {name}!" → ["name"]
*/
extractVariables(template: string): string[] {
const regex = /\{(\w+)\}/g;
const matches = Array.from(template.matchAll(regex));
return matches.map(m => m[1]);
}
/**
* 쿼리 결과와 템플릿 변수 매칭 검증
*/
validateVariableMapping(
templateVariables: string[],
queryFields: string[]
): {
valid: boolean;
missing: string[];
available: string[];
} {
const missing = templateVariables.filter(v => !queryFields.includes(v));
return {
valid: missing.length === 0,
missing,
available: queryFields,
};
}
/**
* 대량 발송용: 각 행마다 템플릿 치환
*/
async processMailData(
templateHtml: string,
templateSubject: string,
queryResult: QueryResult
): Promise<Array<{
email: string;
subject: string;
content: string;
variables: Record<string, any>;
}>> {
if (!queryResult.success || !queryResult.data) {
throw new Error('Invalid query result');
}
const emailFields = this.detectEmailFields(queryResult.fields || []);
if (emailFields.length === 0) {
throw new Error('No email field found in query result');
}
const emailField = emailFields[0]; // 첫 번째 이메일 필드 사용
return queryResult.data.map(row => {
const email = row[emailField];
const subject = this.replaceVariables(templateSubject, row);
const content = this.replaceVariables(templateHtml, row);
return {
email,
subject,
content,
variables: row,
};
});
}
/**
* 쿼리 테스트 (파라미터 값 미리보기)
*/
async testQuery(
sql: string,
sampleParams: QueryParameter[]
): Promise<{
success: boolean;
preview: any[];
totalRows: number;
fields: string[];
emailFields: string[];
error?: string;
}> {
try {
const result = await this.executeQuery(sql, sampleParams);
if (!result.success) {
return {
success: false,
preview: [],
totalRows: 0,
fields: [],
emailFields: [],
error: result.error,
};
}
const fields = result.fields || [];
const emailFields = this.detectEmailFields(fields);
return {
success: true,
preview: (result.data || []).slice(0, 5), // 최대 5개만 미리보기
totalRows: (result.data || []).length,
fields,
emailFields,
};
} catch (error: unknown) {
const err = error as Error;
return {
success: false,
preview: [],
totalRows: 0,
fields: [],
emailFields: [],
error: err.message,
};
}
}
}
export const mailQueryService = new MailQueryService();

View File

@@ -0,0 +1,213 @@
/**
* 간단한 메일 발송 서비스 (쿼리 제외)
* Nodemailer를 사용한 직접 발송
*/
import nodemailer from 'nodemailer';
import { mailAccountFileService } from './mailAccountFileService';
import { mailTemplateFileService } from './mailTemplateFileService';
export interface SendMailRequest {
accountId: string;
templateId?: string;
to: string[]; // 수신자 이메일 배열
subject: string;
variables?: Record<string, string>; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
}
export interface SendMailResult {
success: boolean;
messageId?: string;
accepted?: string[];
rejected?: string[];
error?: string;
}
class MailSendSimpleService {
/**
* 단일 메일 발송 또는 소규모 발송
*/
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
try {
// 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
// 2. 계정 활성화 확인
if (account.status !== 'active') {
throw new Error('비활성 상태의 계정입니다.');
}
// 3. HTML 생성 (템플릿 또는 커스텀)
let htmlContent = request.customHtml || '';
if (!htmlContent && request.templateId) {
const template = await mailTemplateFileService.getTemplateById(request.templateId);
if (!template) {
throw new Error('템플릿을 찾을 수 없습니다.');
}
htmlContent = this.renderTemplate(template, request.variables);
}
if (!htmlContent) {
throw new Error('메일 내용이 없습니다.');
}
// 4. SMTP 연결 생성
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: account.smtpSecure, // SSL/TLS
auth: {
user: account.smtpUsername,
pass: account.smtpPassword,
},
});
// 5. 메일 발송
const info = await transporter.sendMail({
from: `"${account.name}" <${account.email}>`,
to: request.to.join(', '),
subject: this.replaceVariables(request.subject, request.variables),
html: htmlContent,
});
return {
success: true,
messageId: info.messageId,
accepted: info.accepted as string[],
rejected: info.rejected as string[],
};
} catch (error) {
const err = error as Error;
return {
success: false,
error: err.message,
};
}
}
/**
* 템플릿 렌더링 (간단 버전)
*/
private renderTemplate(
template: any,
variables?: Record<string, string>
): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
template.components.forEach((component: any) => {
switch (component.type) {
case 'text':
let content = component.content || '';
if (variables) {
content = this.replaceVariables(content, variables);
}
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
break;
case 'button':
let buttonText = component.text || 'Button';
if (variables) {
buttonText = this.replaceVariables(buttonText, variables);
}
html += `
<a href="${component.url || '#'}" style="
display: inline-block;
padding: 12px 24px;
background-color: ${component.styles?.backgroundColor || '#007bff'};
color: ${component.styles?.color || 'white'};
text-decoration: none;
border-radius: 4px;
${this.styleObjectToString(component.styles)}
">${buttonText}</a>
`;
break;
case 'image':
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
break;
case 'spacer':
html += `<div style="height: ${component.height || 20}px;"></div>`;
break;
}
});
html += '</div>';
return html;
}
/**
* 변수 치환
*/
private replaceVariables(text: string, variables?: Record<string, string>): string {
if (!variables) return text;
let result = text;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, value);
});
return result;
}
/**
* 스타일 객체를 CSS 문자열로 변환
*/
private styleObjectToString(styles?: Record<string, string>): string {
if (!styles) return '';
return Object.entries(styles)
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
.join('; ');
}
/**
* camelCase를 kebab-case로 변환
*/
private camelToKebab(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}
/**
* SMTP 연결 테스트
*/
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
try {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('계정을 찾을 수 없습니다.');
}
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: account.smtpSecure,
auth: {
user: account.smtpUsername,
pass: account.smtpPassword,
},
});
await transporter.verify();
return {
success: true,
message: 'SMTP 연결 성공!',
};
} catch (error) {
const err = error as Error;
return {
success: false,
message: `연결 실패: ${err.message}`,
};
}
}
}
export const mailSendSimpleService = new MailSendSimpleService();

View File

@@ -0,0 +1,231 @@
import fs from 'fs/promises';
import path from 'path';
import { MailComponent, QueryConfig } from './mailQueryService';
export interface MailTemplate {
id: string;
name: string;
subject: string;
components: MailComponent[];
queryConfig?: {
queries: QueryConfig[];
};
recipientConfig?: {
type: 'query' | 'manual';
emailField?: string;
nameField?: string;
queryId?: string;
manualList?: Array<{ email: string; name?: string }>;
};
category?: string;
createdAt: string;
updatedAt: string;
}
class MailTemplateFileService {
private templatesDir: string;
constructor() {
// uploads/mail-templates 디렉토리 사용
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates');
this.ensureDirectoryExists();
}
/**
* 템플릿 디렉토리 생성 (없으면)
*/
private async ensureDirectoryExists() {
try {
await fs.access(this.templatesDir);
} catch {
await fs.mkdir(this.templatesDir, { recursive: true });
}
}
/**
* 템플릿 파일 경로 생성
*/
private getTemplatePath(id: string): string {
return path.join(this.templatesDir, `${id}.json`);
}
/**
* 모든 템플릿 목록 조회
*/
async getAllTemplates(): Promise<MailTemplate[]> {
await this.ensureDirectoryExists();
try {
const files = await fs.readdir(this.templatesDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
const templates = await Promise.all(
jsonFiles.map(async (file) => {
const content = await fs.readFile(
path.join(this.templatesDir, file),
'utf-8'
);
return JSON.parse(content) as MailTemplate;
})
);
// 최신순 정렬
return templates.sort((a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
} catch (error) {
return [];
}
}
/**
* 특정 템플릿 조회
*/
async getTemplateById(id: string): Promise<MailTemplate | null> {
try {
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* 템플릿 생성
*/
async createTemplate(
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'>
): Promise<MailTemplate> {
const id = `template-${Date.now()}`;
const now = new Date().toISOString();
const template: MailTemplate = {
...data,
id,
createdAt: now,
updatedAt: now,
};
await fs.writeFile(
this.getTemplatePath(id),
JSON.stringify(template, null, 2),
'utf-8'
);
return template;
}
/**
* 템플릿 수정
*/
async updateTemplate(
id: string,
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
): Promise<MailTemplate | null> {
const existing = await this.getTemplateById(id);
if (!existing) {
return null;
}
const updated: MailTemplate = {
...existing,
...data,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await fs.writeFile(
this.getTemplatePath(id),
JSON.stringify(updated, null, 2),
'utf-8'
);
return updated;
}
/**
* 템플릿 삭제
*/
async deleteTemplate(id: string): Promise<boolean> {
try {
await fs.unlink(this.getTemplatePath(id));
return true;
} catch {
return false;
}
}
/**
* 템플릿을 HTML로 렌더링
*/
renderTemplateToHtml(components: MailComponent[]): string {
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
components.forEach(comp => {
const styles = Object.entries(comp.styles || {})
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
.join('; ');
switch (comp.type) {
case 'text':
html += `<div style="${styles}">${comp.content || ''}</div>`;
break;
case 'button':
html += `<div style="text-align: center; ${styles}">
<a href="${comp.url || '#'}"
style="display: inline-block; padding: 12px 24px; text-decoration: none;
background-color: ${comp.styles?.backgroundColor || '#007bff'};
color: ${comp.styles?.color || '#fff'};
border-radius: 4px;">
${comp.text || 'Button'}
</a>
</div>`;
break;
case 'image':
html += `<div style="${styles}">
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" />
</div>`;
break;
case 'spacer':
html += `<div style="height: ${comp.height || 20}px;"></div>`;
break;
}
});
html += '</div>';
return html;
}
/**
* camelCase를 kebab-case로 변환
*/
private camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
/**
* 카테고리별 템플릿 조회
*/
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates();
return allTemplates.filter(t => t.category === category);
}
/**
* 템플릿 검색
*/
async searchTemplates(keyword: string): Promise<MailTemplate[]> {
const allTemplates = await this.getAllTemplates();
const lowerKeyword = keyword.toLowerCase();
return allTemplates.filter(t =>
t.name.toLowerCase().includes(lowerKeyword) ||
t.subject.toLowerCase().includes(lowerKeyword) ||
t.category?.toLowerCase().includes(lowerKeyword)
);
}
}
export const mailTemplateFileService = new MailTemplateFileService();