메일 관리 작업 저장용 커밋
This commit is contained in:
291
backend-node/package-lock.json
generated
291
backend-node/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
201
backend-node/src/controllers/mailAccountFileController.ts
Normal file
201
backend-node/src/controllers/mailAccountFileController.ts
Normal 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();
|
||||
|
||||
213
backend-node/src/controllers/mailQueryController.ts
Normal file
213
backend-node/src/controllers/mailQueryController.ts
Normal 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();
|
||||
|
||||
96
backend-node/src/controllers/mailSendSimpleController.ts
Normal file
96
backend-node/src/controllers/mailSendSimpleController.ts
Normal 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();
|
||||
|
||||
258
backend-node/src/controllers/mailTemplateFileController.ts
Normal file
258
backend-node/src/controllers/mailTemplateFileController.ts
Normal 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();
|
||||
|
||||
14
backend-node/src/routes/mailAccountFileRoutes.ts
Normal file
14
backend-node/src/routes/mailAccountFileRoutes.ts
Normal 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;
|
||||
|
||||
37
backend-node/src/routes/mailQueryRoutes.ts
Normal file
37
backend-node/src/routes/mailQueryRoutes.ts
Normal 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;
|
||||
|
||||
13
backend-node/src/routes/mailSendSimpleRoutes.ts
Normal file
13
backend-node/src/routes/mailSendSimpleRoutes.ts
Normal 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;
|
||||
|
||||
18
backend-node/src/routes/mailTemplateFileRoutes.ts
Normal file
18
backend-node/src/routes/mailTemplateFileRoutes.ts
Normal 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;
|
||||
|
||||
76
backend-node/src/services/encryptionService.ts
Normal file
76
backend-node/src/services/encryptionService.ts
Normal 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();
|
||||
159
backend-node/src/services/mailAccountFileService.ts
Normal file
159
backend-node/src/services/mailAccountFileService.ts
Normal 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();
|
||||
|
||||
241
backend-node/src/services/mailQueryService.ts
Normal file
241
backend-node/src/services/mailQueryService.ts
Normal 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();
|
||||
|
||||
213
backend-node/src/services/mailSendSimpleService.ts
Normal file
213
backend-node/src/services/mailSendSimpleService.ts
Normal 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();
|
||||
|
||||
231
backend-node/src/services/mailTemplateFileService.ts
Normal file
231
backend-node/src/services/mailTemplateFileService.ts
Normal 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();
|
||||
|
||||
Reference in New Issue
Block a user