add email notifications

This commit is contained in:
chark1es 2025-05-29 01:10:25 -07:00
parent 0584f160b2
commit 5d92bcfd1b
10 changed files with 1731 additions and 1 deletions

1
.cursorignore Normal file
View file

@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)

View file

@ -36,6 +36,7 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2", "rehype-expressive-code": "^0.40.2",
"resend": "^4.5.1",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
}, },
"devDependencies": { "devDependencies": {
@ -511,6 +512,8 @@
"@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.20", "", { "dependencies": { "@react-aria/interactions": "^3.24.0", "@react-aria/utils": "^3.28.0", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Y7JbrpheUhNgnJWogDWxuxxiWAnuaW9MKOUY5vD3KOa+vEWuc2IBOGSzOOUkAGnVP4L2rvaHeZIuR5flqyeskA=="], "@react-aria/visually-hidden": ["@react-aria/visually-hidden@3.8.20", "", { "dependencies": { "@react-aria/interactions": "^3.24.0", "@react-aria/utils": "^3.28.0", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-Y7JbrpheUhNgnJWogDWxuxxiWAnuaW9MKOUY5vD3KOa+vEWuc2IBOGSzOOUkAGnVP4L2rvaHeZIuR5flqyeskA=="],
"@react-email/render": ["@react-email/render@1.0.6", "", { "dependencies": { "html-to-text": "9.0.5", "prettier": "3.5.3", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ=="],
"@react-stately/calendar": ["@react-stately/calendar@3.7.1", "", { "dependencies": { "@internationalized/date": "^3.7.0", "@react-stately/utils": "^3.10.5", "@react-types/calendar": "^3.6.1", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DXsJv2Xm1BOqJAx5846TmTG1IZ0oKrBqYAzWZG7hiDq3rPjYGgKtC/iJg9MUev6pHhoZlP9fdRCNFiCfzm5bLQ=="], "@react-stately/calendar": ["@react-stately/calendar@3.7.1", "", { "dependencies": { "@internationalized/date": "^3.7.0", "@react-stately/utils": "^3.10.5", "@react-types/calendar": "^3.6.1", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-DXsJv2Xm1BOqJAx5846TmTG1IZ0oKrBqYAzWZG7hiDq3rPjYGgKtC/iJg9MUev6pHhoZlP9fdRCNFiCfzm5bLQ=="],
"@react-stately/checkbox": ["@react-stately/checkbox@3.6.12", "", { "dependencies": { "@react-stately/form": "^3.1.2", "@react-stately/utils": "^3.10.5", "@react-types/checkbox": "^3.9.2", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gMxrWBl+styUD+2ntNIcviVpGt2Y+cHUGecAiNI3LM8/K6weI7938DWdLdK7i0gDmgSJwhoNRSavMPI1W6aMZQ=="], "@react-stately/checkbox": ["@react-stately/checkbox@3.6.12", "", { "dependencies": { "@react-stately/form": "^3.1.2", "@react-stately/utils": "^3.10.5", "@react-types/checkbox": "^3.9.2", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-gMxrWBl+styUD+2ntNIcviVpGt2Y+cHUGecAiNI3LM8/K6weI7938DWdLdK7i0gDmgSJwhoNRSavMPI1W6aMZQ=="],
@ -651,6 +654,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@shikijs/core": ["@shikijs/core@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-FhsdxMWYu/C11sFisEp7FMGBtX/OSSbnXZDMBhGuUDBNTdsoZlMSgQv5f90rwvzWAdWIW6VobD+G3IrazxA6dQ=="], "@shikijs/core": ["@shikijs/core@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-FhsdxMWYu/C11sFisEp7FMGBtX/OSSbnXZDMBhGuUDBNTdsoZlMSgQv5f90rwvzWAdWIW6VobD+G3IrazxA6dQ=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-eMdcUzN3FMQYxOmRf2rmU8frikzoSHbQDFH2hIuXsrMO+IBOCI9BeeRkCiBkcLDHeRKbOCtYMJK3D6U32ooU9Q=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.2.1", "", { "dependencies": { "@shikijs/types": "3.2.1", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.1.0" } }, "sha512-eMdcUzN3FMQYxOmRf2rmU8frikzoSHbQDFH2hIuXsrMO+IBOCI9BeeRkCiBkcLDHeRKbOCtYMJK3D6U32ooU9Q=="],
@ -985,6 +990,8 @@
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="], "fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="], "fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
@ -1073,6 +1080,8 @@
"html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], "htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
@ -1149,6 +1158,8 @@
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
@ -1363,6 +1374,8 @@
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@ -1371,6 +1384,8 @@
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@ -1431,6 +1446,8 @@
"react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="], "react-icons": ["react-icons@5.4.0", "", { "peerDependencies": { "react": "*" } }, "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ=="],
"react-promise-suspense": ["react-promise-suspense@0.3.4", "", { "dependencies": { "fast-deep-equal": "^2.0.1" } }, "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-textarea-autosize": ["react-textarea-autosize@8.5.8", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iUiIj70JefrTuSJ4LbVFiSqWiHHss5L63L717bqaWHMgkm9sz6eEvro4vZ3uQfGJbevzwT6rHOszHKA8RkhRMg=="], "react-textarea-autosize": ["react-textarea-autosize@8.5.8", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iUiIj70JefrTuSJ4LbVFiSqWiHHss5L63L717bqaWHMgkm9sz6eEvro4vZ3uQfGJbevzwT6rHOszHKA8RkhRMg=="],
@ -1481,6 +1498,8 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resend": ["resend@4.5.1", "", { "dependencies": { "@react-email/render": "1.0.6" } }, "sha512-ryhHpZqCBmuVyzM19IO8Egtc2hkWI4JOL5lf5F3P7Dydu3rFeX6lHNpGqG0tjWoZ63rw0l731JEmuJZBdDm3og=="],
"resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="], "resolve": ["resolve@1.22.8", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw=="],
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
@ -1509,6 +1528,8 @@
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.0.10", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.0.10", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-t44QCeDKAPf1mtQH3fYpWz8IM/DyvHLjs8wUvvwMYxk5moOqCzrMSxK6HQVD0QVmVjXFavoFIPRVrMuJPKAvtg=="],
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="],
"send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="], "send": ["send@1.1.0", "", { "dependencies": { "debug": "^4.3.5", "destroy": "^1.2.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^0.5.2", "http-errors": "^2.0.0", "mime-types": "^2.1.35", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA=="],
@ -1825,6 +1846,8 @@
"@react-aria/toggle/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="], "@react-aria/toggle/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
"@react-email/render/prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="],
"@react-stately/virtualizer/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="], "@react-stately/virtualizer/@react-aria/utils": ["@react-aria/utils@3.28.1", "", { "dependencies": { "@react-aria/ssr": "^3.9.7", "@react-stately/flags": "^3.1.0", "@react-stately/utils": "^3.10.5", "@react-types/shared": "^3.28.0", "@swc/helpers": "^0.5.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-mnHFF4YOVu9BRFQ1SZSKfPhg3z+lBRYoW5mLcYTQihbKhz48+I1sqRkP7ahMITr8ANH3nb34YaMME4XWmK2Mgg=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@ -1869,6 +1892,8 @@
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], "hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
"html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"micromark/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "micromark/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],

View file

@ -46,6 +46,7 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"rehype-expressive-code": "^0.40.2", "rehype-expressive-code": "^0.40.2",
"resend": "^4.5.1",
"tailwindcss": "^3.4.16" "tailwindcss": "^3.4.16"
}, },
"devDependencies": { "devDependencies": {

View file

@ -3,6 +3,7 @@ import { Icon } from '@iconify/react';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { DataSyncService } from '../../../scripts/database/DataSyncService'; import { DataSyncService } from '../../../scripts/database/DataSyncService';
import { Collections } from '../../../schemas/pocketbase/schema'; import { Collections } from '../../../schemas/pocketbase/schema';
import { EmailClient } from '../../../scripts/email/EmailClient';
import ReceiptForm from './ReceiptForm'; import ReceiptForm from './ReceiptForm';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
@ -331,6 +332,14 @@ export default function ReimbursementForm() {
} }
}); });
// Send email notification
try {
await EmailClient.notifySubmission(newReimbursement.id);
} catch (emailError) {
console.error('Failed to send submission email notification:', emailError);
// Don't fail the entire operation if email fails
}
} catch (error) { } catch (error) {
console.error('Error submitting reimbursement request:', error); console.error('Error submitting reimbursement request:', error);
toast.error('Failed to submit reimbursement request. Please try again.'); toast.error('Failed to submit reimbursement request. Please try again.');

View file

@ -5,6 +5,7 @@ import { Get } from '../../../scripts/pocketbase/Get';
import { Update } from '../../../scripts/pocketbase/Update'; import { Update } from '../../../scripts/pocketbase/Update';
import { Authentication } from '../../../scripts/pocketbase/Authentication'; import { Authentication } from '../../../scripts/pocketbase/Authentication';
import { FileManager } from '../../../scripts/pocketbase/FileManager'; import { FileManager } from '../../../scripts/pocketbase/FileManager';
import { EmailClient } from '../../../scripts/email/EmailClient';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase'; import type { Receipt as SchemaReceipt, User, Reimbursement } from '../../../schemas/pocketbase';
@ -443,14 +444,25 @@ export default function ReimbursementManagementPortal() {
if (!userId) throw new Error('User not authenticated'); if (!userId) throw new Error('User not authenticated');
// Store previous status for email notification
const previousStatus = selectedReimbursement?.status || 'unknown';
await update.updateFields('reimbursement', id, { status }); await update.updateFields('reimbursement', id, { status });
// Add audit log for status change // Add audit log for status change
await addAuditLog(id, 'status_change', { await addAuditLog(id, 'status_change', {
from: selectedReimbursement?.status, from: previousStatus,
to: status to: status
}); });
// Send email notification
try {
await EmailClient.notifyStatusChange(id, status, previousStatus, userId);
} catch (emailError) {
console.error('Failed to send email notification:', emailError);
// Don't fail the entire operation if email fails
}
if (showToast) { if (showToast) {
toast.success(`Reimbursement ${status} successfully`); toast.success(`Reimbursement ${status} successfully`);
} }
@ -651,6 +663,21 @@ export default function ReimbursementManagementPortal() {
is_private: isPrivateNote is_private: isPrivateNote
}); });
// Send email notification for public comments
if (!isPrivateNote) {
try {
await EmailClient.notifyComment(
selectedReimbursement.id,
auditNote.trim(),
userId,
isPrivateNote
);
} catch (emailError) {
console.error('Failed to send comment email notification:', emailError);
// Don't fail the entire operation if email fails
}
}
toast.success('Audit note saved successfully'); toast.success('Audit note saved successfully');
setAuditNote(''); setAuditNote('');
setIsPrivateNote(true); setIsPrivateNote(true);

View file

@ -0,0 +1,653 @@
import type { APIRoute } from 'astro';
export const POST: APIRoute = async ({ request }) => {
try {
console.log('📨 Reimbursement email API called');
const {
type,
reimbursementId,
previousStatus,
newStatus,
changedByUserId,
comment,
commentByUserId,
isPrivate,
additionalContext,
authData // Change to authData containing token and model
} = await request.json();
console.log('📋 Request data:', {
type,
reimbursementId,
hasAuthData: !!authData,
authDataHasToken: !!(authData?.token),
authDataHasModel: !!(authData?.model),
commentLength: comment?.length || 0,
commentByUserId,
isPrivate
});
if (!type || !reimbursementId) {
console.error('❌ Missing required parameters');
return new Response(
JSON.stringify({ error: 'Missing required parameters: type and reimbursementId' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// Import Resend and create direct PocketBase connection for server-side use
const { Resend } = await import('resend');
const PocketBase = await import('pocketbase').then(module => module.default);
// Initialize services
const pb = new PocketBase(import.meta.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
const resend = new Resend(import.meta.env.RESEND_API_KEY);
if (!import.meta.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY environment variable is required');
}
// Authenticate with PocketBase if auth data is provided
if (authData && authData.token && authData.model) {
console.log('🔐 Authenticating with PocketBase using provided auth data');
pb.authStore.save(authData.token, authData.model);
console.log('✅ PocketBase authentication successful');
} else {
console.warn('⚠️ No auth data provided, proceeding without authentication');
}
const fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@ieeeucsd.org>';
const replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'treasurer@ieeeucsd.org';
let success = false;
console.log(`🎯 Processing email type: ${type}`);
switch (type) {
case 'status_change':
if (!newStatus) {
return new Response(
JSON.stringify({ error: 'Missing newStatus for status_change notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendStatusChangeEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId,
newStatus,
previousStatus,
changedByUserId,
additionalContext
});
break;
case 'comment':
if (!comment || !commentByUserId) {
console.error('❌ Missing comment or commentByUserId for comment notification');
return new Response(
JSON.stringify({ error: 'Missing comment or commentByUserId for comment notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendCommentEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId,
comment,
commentByUserId,
isPrivate: isPrivate || false
});
break;
case 'submission':
success = await sendSubmissionEmail(pb, resend, fromEmail, replyToEmail, {
reimbursementId
});
break;
case 'test':
const { email } = additionalContext || {};
if (!email) {
return new Response(
JSON.stringify({ error: 'Missing email for test notification' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
success = await sendTestEmail(resend, fromEmail, replyToEmail, email);
break;
default:
console.error('❌ Unknown notification type:', type);
return new Response(
JSON.stringify({ error: `Unknown notification type: ${type}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
console.log(`📊 Email operation result: ${success ? 'SUCCESS' : 'FAILED'}`);
return new Response(
JSON.stringify({
success,
message: success ? 'Email notification sent successfully' : 'Failed to send email notification'
}),
{
status: success ? 200 : 500,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('❌ Error in email notification API:', error);
return new Response(
JSON.stringify({
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
// Helper functions for different email types
async function sendStatusChangeEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('📧 Starting status change email process...');
console.log('Environment check:', {
hasResendKey: !!import.meta.env.RESEND_API_KEY,
fromEmail,
replyToEmail,
pocketbaseUrl: import.meta.env.POCKETBASE_URL
});
// Get reimbursement details
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
console.log('✅ Reimbursement fetched:', { id: reimbursement.id, title: reimbursement.title });
// Get submitter user details
console.log('👤 Fetching user details for:', reimbursement.submitted_by);
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('❌ User not found or no email:', reimbursement.submitted_by);
return false;
}
console.log('✅ User fetched:', { id: user.id, name: user.name, email: user.email });
// Get changed by user name if provided
let changedByName = 'System';
if (data.changedByUserId) {
try {
const changedByUser = await pb.collection('users').getOne(data.changedByUserId);
changedByName = changedByUser?.name || 'Unknown User';
console.log('👤 Changed by user:', changedByName);
} catch (error) {
console.warn('⚠️ Could not get changed by user name:', error);
}
}
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
const statusColor = getStatusColor(data.newStatus);
const statusText = getStatusText(data.newStatus);
console.log('📝 Email details:', {
to: user.email,
subject,
status: data.newStatus
});
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #666;">Status:</span>
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500; margin-left: 10px;">${statusText}</span>
</div>
${data.previousStatus && data.previousStatus !== data.newStatus ? `
<div style="color: #666; font-size: 14px;">
Changed from: <span style="text-decoration: line-through;">${getStatusText(data.previousStatus)}</span> <strong>${statusText}</strong>
</div>
` : ''}
${changedByName !== 'System' ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
Updated by: ${changedByName}
</div>
` : ''}
${data.newStatus === 'rejected' && data.additionalContext?.rejectionReason ? `
<div style="background: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; margin-top: 15px;">
<div style="font-weight: bold; color: #721c24; margin-bottom: 8px;">Rejection Reason:</div>
<div style="color: #721c24; font-style: italic;">${data.additionalContext.rejectionReason}</div>
</div>
` : ''}
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
${getNextStepsText(data.newStatus)}
</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('📤 Attempting to send email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('✅ Resend response:', result);
console.log('🎉 Status change email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send status change email:', error);
console.error('Error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}
async function sendCommentEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
console.log('💬 Starting comment email process...');
console.log('Comment data received:', {
reimbursementId: data.reimbursementId,
commentByUserId: data.commentByUserId,
isPrivate: data.isPrivate,
commentLength: data.comment?.length || 0
});
// Don't send emails for private comments
if (data.isPrivate) {
console.log('🔒 Comment is private, skipping email notification');
return true;
}
// Get reimbursement details
console.log('🔍 Fetching reimbursement details for:', data.reimbursementId);
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
console.log('✅ Reimbursement fetched:', {
id: reimbursement.id,
title: reimbursement.title,
submitted_by: reimbursement.submitted_by
});
// Get submitter user details
console.log('👤 Fetching submitter user details for:', reimbursement.submitted_by);
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('❌ User not found or no email:', reimbursement.submitted_by);
return false;
}
console.log('✅ Submitter user fetched:', {
id: user.id,
name: user.name,
email: user.email
});
// Get commenter user name
console.log('👤 Fetching commenter user details for:', data.commentByUserId);
let commentByName = 'Unknown User';
try {
const commentByUser = await pb.collection('users').getOne(data.commentByUserId);
commentByName = commentByUser?.name || 'Unknown User';
console.log('✅ Commenter user fetched:', {
id: commentByUser?.id,
name: commentByName
});
} catch (error) {
console.warn('⚠️ Could not get commenter user name:', error);
}
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
console.log('📝 Comment email details:', {
to: user.email,
subject,
commentBy: commentByName,
commentPreview: data.comment.substring(0, 50) + (data.comment.length > 50 ? '...' : '')
});
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
<p>Hello ${user.name},</p>
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentByName}
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
<p style="margin: 0; font-style: italic;">${data.comment}</p>
</div>
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
<span style="background: ${getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
${getStatusText(reimbursement.status)}
</span>
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
</table>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('📤 Attempting to send comment email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('✅ Resend comment email response:', result);
console.log('🎉 Comment email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send comment email:', error);
console.error('Comment email error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}
async function sendSubmissionEmail(pb: any, resend: any, fromEmail: string, replyToEmail: string, data: any): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await pb.collection('reimbursement').getOne(data.reimbursementId);
// Get submitter user details
const user = await pb.collection('users').getOne(reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;"> Reimbursement Submitted Successfully</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
<td style="padding: 8px 0;">
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
Submitted
</span>
</td>
</tr>
</table>
</div>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
<ul style="margin: 0; padding-left: 20px; color: #155724;">
<li>Your receipts will be reviewed by our team</li>
<li>You'll receive email updates as the status changes</li>
<li>Once approved, payment will be processed</li>
<li>Typical processing time is 1-2 weeks</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await resend.emails.send({
from: fromEmail,
to: [user.email],
replyTo: replyToEmail,
subject,
html,
});
console.log('Submission confirmation email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send submission confirmation email:', error);
return false;
}
}
async function sendTestEmail(resend: any, fromEmail: string, replyToEmail: string, email: string): Promise<boolean> {
try {
console.log('🧪 Starting test email process...');
console.log('Test email configuration:', {
fromEmail,
replyToEmail,
toEmail: email,
hasResend: !!resend
});
const subject = 'Test Email from IEEE UCSD Reimbursement System';
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">🧪 Test Email</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Email System Test</h2>
<p>This is a test email from the IEEE UCSD Reimbursement System.</p>
<p>If you receive this email, the notification system is working correctly!</p>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<p style="margin: 0; color: #155724;"> Email delivery successful</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is a test notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${replyToEmail}" style="color: #667eea;">${replyToEmail}</a></p>
</div>
</body>
</html>
`;
console.log('📤 Sending test email via Resend...');
const result = await resend.emails.send({
from: fromEmail,
to: [email],
replyTo: replyToEmail,
subject,
html,
});
console.log('✅ Resend test email response:', result);
console.log('🎉 Test email sent successfully!');
return true;
} catch (error) {
console.error('❌ Failed to send test email:', error);
console.error('Test email error details:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false;
}
}
// Helper functions
function getStatusColor(status: string): string {
switch (status) {
case 'submitted': return '#ffc107';
case 'under_review': return '#17a2b8';
case 'approved': return '#28a745';
case 'rejected': return '#dc3545';
case 'in_progress': return '#6f42c1';
case 'paid': return '#20c997';
default: return '#6c757d';
}
}
function getStatusText(status: string): string {
switch (status) {
case 'submitted': return 'Submitted';
case 'under_review': return 'Under Review';
case 'approved': return 'Approved';
case 'rejected': return 'Rejected';
case 'in_progress': return 'In Progress';
case 'paid': return 'Paid';
default: return status.charAt(0).toUpperCase() + status.slice(1);
}
}
function getNextStepsText(status: string): string {
switch (status) {
case 'submitted':
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
case 'under_review':
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
case 'approved':
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
case 'rejected':
return 'Your reimbursement has been rejected. Please review the rejection reason above and reach out to our treasurer if you have questions or need to resubmit with corrections.';
case 'in_progress':
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
case 'paid':
return 'Your reimbursement has been completed! Please check your account for the payment.';
default:
return 'Check your dashboard for more details about your reimbursement status.';
}
}

View file

@ -0,0 +1,152 @@
/**
* Client-side helper for sending email notifications via API routes
* This runs in the browser and calls the server-side email API
*/
import { Authentication } from '../pocketbase/Authentication';
interface EmailNotificationRequest {
type: 'status_change' | 'comment' | 'submission' | 'test';
reimbursementId: string;
previousStatus?: string;
newStatus?: string;
changedByUserId?: string;
comment?: string;
commentByUserId?: string;
isPrivate?: boolean;
additionalContext?: Record<string, any>;
authData?: { token: string; model: any };
}
interface EmailNotificationResponse {
success: boolean;
message: string;
error?: string;
details?: string;
}
export class EmailClient {
private static getAuthData(): { token: string; model: any } | null {
try {
const auth = Authentication.getInstance();
const token = auth.getAuthToken();
const model = auth.getCurrentUser();
if (token && model) {
return { token, model };
}
return null;
} catch (error) {
console.warn('Could not get auth data:', error);
return null;
}
}
private static async sendEmailNotification(request: EmailNotificationRequest): Promise<boolean> {
try {
const authData = this.getAuthData();
const requestWithAuth = {
...request,
authData
};
const response = await fetch('/api/email/send-reimbursement-notification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestWithAuth),
});
const result: EmailNotificationResponse = await response.json();
if (!response.ok) {
console.error('Email notification API error:', result.error || result.message);
return false;
}
return result.success;
} catch (error) {
console.error('Failed to send email notification:', error);
return false;
}
}
/**
* Send status change notification
*/
static async notifyStatusChange(
reimbursementId: string,
newStatus: string,
previousStatus?: string,
changedByUserId?: string,
additionalContext?: Record<string, any>
): Promise<boolean> {
return this.sendEmailNotification({
type: 'status_change',
reimbursementId,
newStatus,
previousStatus,
changedByUserId,
additionalContext
});
}
/**
* Send comment notification
*/
static async notifyComment(
reimbursementId: string,
comment: string,
commentByUserId: string,
isPrivate: boolean = false
): Promise<boolean> {
return this.sendEmailNotification({
type: 'comment',
reimbursementId,
comment,
commentByUserId,
isPrivate
});
}
/**
* Send submission confirmation
*/
static async notifySubmission(reimbursementId: string): Promise<boolean> {
return this.sendEmailNotification({
type: 'submission',
reimbursementId
});
}
/**
* Send rejection notification with reason
*/
static async notifyRejection(
reimbursementId: string,
rejectionReason: string,
previousStatus?: string,
changedByUserId?: string
): Promise<boolean> {
return this.sendEmailNotification({
type: 'status_change',
reimbursementId,
newStatus: 'rejected',
previousStatus,
changedByUserId,
additionalContext: { rejectionReason }
});
}
/**
* Send test email
*/
static async sendTestEmail(email: string): Promise<boolean> {
return this.sendEmailNotification({
type: 'test',
reimbursementId: 'test', // Required but not used for test emails
additionalContext: { email }
});
}
}

View file

@ -0,0 +1,410 @@
import { Resend } from 'resend';
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
// Define email template types
export type EmailTemplateType =
| 'reimbursement_status_changed'
| 'reimbursement_comment_added'
| 'reimbursement_submitted'
| 'reimbursement_approved'
| 'reimbursement_rejected'
| 'reimbursement_paid';
// Email template data interfaces
export interface StatusChangeEmailData {
user: User;
reimbursement: Reimbursement;
previousStatus: string;
newStatus: string;
changedBy?: string;
comment?: string;
}
export interface CommentEmailData {
user: User;
reimbursement: Reimbursement;
comment: string;
commentBy: string;
isPrivate: boolean;
}
export interface ReimbursementEmailData {
user: User;
reimbursement: Reimbursement;
receipts?: Receipt[];
additionalData?: Record<string, any>;
}
export class EmailService {
private resend: Resend;
private fromEmail: string;
private replyToEmail: string;
constructor() {
// Initialize Resend with API key from environment
// Use import.meta.env as used throughout the Astro project
const apiKey = import.meta.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('RESEND_API_KEY environment variable is required');
}
this.resend = new Resend(apiKey);
this.fromEmail = import.meta.env.FROM_EMAIL || 'IEEE UCSD <noreply@transactional.ieeeatucsd.org>';
this.replyToEmail = import.meta.env.REPLY_TO_EMAIL || 'ieee@ucsd.edu';
}
private static instance: EmailService | null = null;
public static getInstance(): EmailService {
if (!EmailService.instance) {
EmailService.instance = new EmailService();
}
return EmailService.instance;
}
/**
* Send reimbursement status change notification
*/
async sendStatusChangeEmail(data: StatusChangeEmailData): Promise<boolean> {
try {
const { user, reimbursement, previousStatus, newStatus, changedBy, comment } = data;
const subject = `Reimbursement Status Updated: ${reimbursement.title}`;
const statusColor = this.getStatusColor(newStatus);
const statusText = this.getStatusText(newStatus);
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Update</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Status Update</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request "<strong>${reimbursement.title}</strong>" has been updated.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${statusColor}; margin: 20px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<span style="font-weight: bold; color: #666;">Status Change:</span>
<span style="background: ${statusColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 14px; font-weight: 500;">${statusText}</span>
</div>
${previousStatus !== newStatus ? `
<div style="color: #666; font-size: 14px;">
Changed from: <span style="text-decoration: line-through;">${this.getStatusText(previousStatus)}</span> <strong>${statusText}</strong>
</div>
` : ''}
${changedBy ? `
<div style="color: #666; font-size: 14px; margin-top: 10px;">
Updated by: ${changedBy}
</div>
` : ''}
</div>
${comment ? `
<div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #3498db;">
<h4 style="margin: 0 0 10px 0; color: #2980b9;">Additional Note:</h4>
<p style="margin: 0; font-style: italic;">${comment}</p>
</div>
` : ''}
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0;">${reimbursement.payment_method}</td>
</tr>
</table>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; border-left: 4px solid #ffc107; margin: 20px 0;">
<p style="margin: 0; font-size: 14px;"><strong>Next Steps:</strong></p>
<p style="margin: 5px 0 0 0; font-size: 14px;">
${this.getNextStepsText(newStatus)}
</p>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Status change email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send status change email:', error);
return false;
}
}
/**
* Send comment notification email
*/
async sendCommentEmail(data: CommentEmailData): Promise<boolean> {
try {
const { user, reimbursement, comment, commentBy, isPrivate } = data;
// Don't send email for private comments unless the user is the recipient
if (isPrivate) {
return true; // Silently skip private comments for now
}
const subject = `New Comment on Reimbursement: ${reimbursement.title}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;">IEEE UCSD Reimbursement Comment</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">New Comment Added</h2>
<p>Hello ${user.name},</p>
<p>A new comment has been added to your reimbursement request "<strong>${reimbursement.title}</strong>".</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #3498db; margin: 20px 0;">
<div style="margin-bottom: 15px;">
<span style="font-weight: bold; color: #2980b9;">Comment by:</span> ${commentBy}
</div>
<div style="background: #f8f9fa; padding: 15px; border-radius: 6px;">
<p style="margin: 0; font-style: italic;">${comment}</p>
</div>
</div>
<div style="margin: 25px 0;">
<h3 style="color: #2c3e50; margin-bottom: 15px;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Status:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">
<span style="background: ${this.getStatusColor(reimbursement.status)}; color: white; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
${this.getStatusText(reimbursement.status)}
</span>
</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
</table>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Comment email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send comment email:', error);
return false;
}
}
/**
* Send reimbursement submission confirmation
*/
async sendSubmissionConfirmation(data: ReimbursementEmailData): Promise<boolean> {
try {
const { user, reimbursement } = data;
const subject = `Reimbursement Submitted: ${reimbursement.title}`;
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${subject}</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #28a745 0%, #20c997 100%); padding: 30px; border-radius: 10px; margin-bottom: 30px;">
<h1 style="color: white; margin: 0; font-size: 24px;"> Reimbursement Submitted Successfully</h1>
</div>
<div style="background: #f8f9fa; padding: 25px; border-radius: 10px; margin-bottom: 25px;">
<h2 style="margin-top: 0; color: #2c3e50;">Submission Confirmed</h2>
<p>Hello ${user.name},</p>
<p>Your reimbursement request has been successfully submitted and is now under review.</p>
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h3 style="margin-top: 0; color: #155724;">Reimbursement Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold; width: 30%;">Title:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.title}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Amount:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">$${reimbursement.total_amount.toFixed(2)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Date of Purchase:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${new Date(reimbursement.date_of_purchase).toLocaleDateString()}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Department:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.department.charAt(0).toUpperCase() + reimbursement.department.slice(1)}</td>
</tr>
<tr>
<td style="padding: 8px 0; border-bottom: 1px solid #eee; font-weight: bold;">Payment Method:</td>
<td style="padding: 8px 0; border-bottom: 1px solid #eee;">${reimbursement.payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Status:</td>
<td style="padding: 8px 0;">
<span style="background: #ffc107; color: #212529; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
Submitted
</span>
</td>
</tr>
</table>
</div>
<div style="background: #d4edda; padding: 15px; border-radius: 8px; border-left: 4px solid #28a745; margin: 20px 0;">
<h4 style="margin: 0 0 10px 0; color: #155724;">What happens next?</h4>
<ul style="margin: 0; padding-left: 20px; color: #155724;">
<li>Your receipts will be reviewed by our team</li>
<li>You'll receive email updates as the status changes</li>
<li>Once approved, payment will be processed</li>
<li>Typical processing time is 1-2 weeks</li>
</ul>
</div>
</div>
<div style="text-align: center; padding: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px;">
<p>This is an automated notification from IEEE UCSD Reimbursement System.</p>
<p>If you have any questions, please contact us at <a href="mailto:${this.replyToEmail}" style="color: #667eea;">${this.replyToEmail}</a></p>
</div>
</body>
</html>
`;
const result = await this.resend.emails.send({
from: this.fromEmail,
to: [user.email],
replyTo: this.replyToEmail,
subject,
html,
});
console.log('Submission confirmation email sent successfully:', result);
return true;
} catch (error) {
console.error('Failed to send submission confirmation email:', error);
return false;
}
}
/**
* Get status color for styling
*/
private getStatusColor(status: string): string {
switch (status) {
case 'submitted': return '#ffc107';
case 'under_review': return '#17a2b8';
case 'approved': return '#28a745';
case 'rejected': return '#dc3545';
case 'in_progress': return '#6f42c1';
case 'paid': return '#20c997';
default: return '#6c757d';
}
}
/**
* Get human-readable status text
*/
private getStatusText(status: string): string {
switch (status) {
case 'submitted': return 'Submitted';
case 'under_review': return 'Under Review';
case 'approved': return 'Approved';
case 'rejected': return 'Rejected';
case 'in_progress': return 'In Progress';
case 'paid': return 'Paid';
default: return status.charAt(0).toUpperCase() + status.slice(1);
}
}
/**
* Get next steps text based on status
*/
private getNextStepsText(status: string): string {
switch (status) {
case 'submitted':
return 'Your reimbursement is in the queue for review. We\'ll notify you once it\'s being processed.';
case 'under_review':
return 'Our team is currently reviewing your receipts and documentation. No action needed from you.';
case 'approved':
return 'Your reimbursement has been approved! Payment processing will begin shortly.';
case 'rejected':
return 'Your reimbursement has been rejected. Please review the comments and reach out if you have questions.';
case 'in_progress':
return 'Payment is being processed. You should receive your reimbursement within 1-2 business days.';
case 'paid':
return 'Your reimbursement has been completed! Please check your account for the payment.';
default:
return 'Check your dashboard for more details about your reimbursement status.';
}
}
}

142
src/scripts/email/README.md Normal file
View file

@ -0,0 +1,142 @@
# Email Notification System
This directory contains the email notification system for the IEEE UCSD reimbursement portal using Resend.
## Setup
### Environment Variables
Add the following environment variables to your `.env` file:
```bash
# PocketBase Configuration
POCKETBASE_URL=https://pocketbase.ieeeucsd.org
# Resend API Configuration
RESEND_API_KEY=your_resend_api_key_here
# Email Configuration
FROM_EMAIL="IEEE UCSD <noreply@ieeeucsd.org>"
REPLY_TO_EMAIL="treasurer@ieeeucsd.org"
```
**Note**: This project uses Astro's standard environment variable pattern with `import.meta.env.VARIABLE_NAME`. No PUBLIC_ prefix is needed as these are used in API routes and server-side code.
### Getting a Resend API Key
1. Sign up for a [Resend account](https://resend.com)
2. Go to your dashboard and create a new API key
3. Add the API key to your environment variables
## Features
### Automatic Email Notifications
The system automatically sends emails for the following events:
1. **Reimbursement Submitted** - Confirmation email when a user submits a new reimbursement request
2. **Status Changes** - Notification when reimbursement status is updated (submitted, under review, approved, rejected, in progress, paid)
3. **Comments Added** - Notification when someone adds a public comment to a reimbursement
4. **Rejections with Reasons** - Detailed rejection notification including the specific reason for rejection
Note: Private comments are not sent via email to maintain privacy.
### Email Templates
All emails include:
- Professional IEEE UCSD branding
- Responsive design for mobile and desktop
- Clear status indicators with color coding
- Reimbursement details summary
- Next steps information
- Contact information for support
## Usage
### In React Components (Client-side)
```typescript
import { EmailClient } from '../../../scripts/email/EmailClient';
// Send status change notification
await EmailClient.notifyStatusChange(reimbursementId, newStatus, previousStatus, userId);
// Send comment notification
await EmailClient.notifyComment(reimbursementId, comment, commentByUserId, isPrivate);
// Send submission confirmation
await EmailClient.notifySubmission(reimbursementId);
// Send rejection with reason (recommended for rejections)
await EmailClient.notifyRejection(reimbursementId, rejectionReason, previousStatus, userId);
// Send test email
await EmailClient.sendTestEmail('your-email@example.com');
// Alternative: Send rejection via notifyStatusChange with additionalContext
await EmailClient.notifyStatusChange(
reimbursementId,
'rejected',
previousStatus,
userId,
{ rejectionReason: 'Missing receipt for coffee purchase. Please resubmit with proper documentation.' }
);
```
### API Route (Server-side)
The API route at `/api/email/send-reimbursement-notification` accepts POST requests with the following structure:
```json
{
"type": "status_change" | "comment" | "submission" | "test",
"reimbursementId": "string",
"newStatus": "string", // for status_change
"previousStatus": "string", // for status_change
"changedByUserId": "string", // for status_change
"comment": "string", // for comment
"commentByUserId": "string", // for comment
"isPrivate": boolean, // for comment
"additionalContext": {}, // for additional data
"authData": { // Authentication data for PocketBase access
"token": "string",
"model": {}
}
}
```
## Architecture
The email system uses a client-server architecture for security and authentication:
- `EmailService.ts` - Core email service using Resend (server-side only)
- `ReimbursementEmailNotifications.ts` - High-level notification service (server-side only)
- `EmailClient.ts` - Client-side helper that calls the API with authentication
- `/api/email/send-reimbursement-notification.ts` - API route that handles server-side email sending with PocketBase authentication
### Authentication Flow
1. **Client-side**: `EmailClient` gets the current user's authentication token and model from the `Authentication` service
2. **API Request**: The auth data is sent to the server-side API route
3. **Server-side**: The API route authenticates with PocketBase using the provided auth data
4. **Database Access**: The authenticated PocketBase connection can access protected collections
5. **Email Sending**: Emails are sent using the Resend service with proper user data
This ensures that:
- API keys are never exposed to the client-side code
- Only authenticated users can trigger email notifications
- The server can access protected PocketBase collections
- Email operations respect user permissions and data security
## Error Handling
Email failures are logged but do not prevent the main operations from completing. This ensures that reimbursement processing continues even if email delivery fails.
## Security
- API keys are loaded from environment variables server-side only
- Authentication tokens are passed securely from client to server
- Email addresses are validated before sending
- Private comments are not sent via email (configurable)
- All emails include appropriate contact information
- PocketBase collection access respects authentication and permissions

View file

@ -0,0 +1,310 @@
import { EmailService, type StatusChangeEmailData, type CommentEmailData, type ReimbursementEmailData } from './EmailService';
import { Get } from '../pocketbase/Get';
import type { User, Reimbursement, Receipt } from '../../schemas/pocketbase';
export class ReimbursementEmailNotifications {
private emailService: EmailService;
private get: Get;
constructor() {
this.emailService = EmailService.getInstance();
this.get = Get.getInstance();
}
private static instance: ReimbursementEmailNotifications | null = null;
public static getInstance(): ReimbursementEmailNotifications {
if (!ReimbursementEmailNotifications.instance) {
ReimbursementEmailNotifications.instance = new ReimbursementEmailNotifications();
}
return ReimbursementEmailNotifications.instance;
}
/**
* Send notification when reimbursement status changes
*/
async notifyStatusChange(
reimbursementId: string,
previousStatus: string,
newStatus: string,
changedByUserId?: string,
comment?: string
): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Get changed by user name if provided
let changedByName = 'System';
if (changedByUserId) {
try {
const changedByUser = await this.get.getOne<User>('users', changedByUserId);
changedByName = changedByUser?.name || 'Unknown User';
} catch (error) {
console.warn('Could not get changed by user name:', error);
}
}
const emailData: StatusChangeEmailData = {
user,
reimbursement,
previousStatus,
newStatus,
changedBy: changedByName,
comment
};
return await this.emailService.sendStatusChangeEmail(emailData);
} catch (error) {
console.error('Failed to send status change notification:', error);
return false;
}
}
/**
* Send notification when a comment is added to a reimbursement
*/
async notifyComment(
reimbursementId: string,
comment: string,
commentByUserId: string,
isPrivate: boolean = false
): Promise<boolean> {
try {
// Don't send emails for private comments (for now)
if (isPrivate) {
return true;
}
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Don't send email if the commenter is the same as the submitter
if (commentByUserId === reimbursement.submitted_by) {
return true;
}
// Get commenter user name
let commentByName = 'Unknown User';
try {
const commentByUser = await this.get.getOne<User>('users', commentByUserId);
commentByName = commentByUser?.name || 'Unknown User';
} catch (error) {
console.warn('Could not get commenter user name:', error);
}
const emailData: CommentEmailData = {
user,
reimbursement,
comment,
commentBy: commentByName,
isPrivate
};
return await this.emailService.sendCommentEmail(emailData);
} catch (error) {
console.error('Failed to send comment notification:', error);
return false;
}
}
/**
* Send submission confirmation email
*/
async notifySubmission(reimbursementId: string): Promise<boolean> {
try {
// Get reimbursement details
const reimbursement = await this.get.getOne<Reimbursement>('reimbursement', reimbursementId);
if (!reimbursement) {
console.error('Reimbursement not found:', reimbursementId);
return false;
}
// Get submitter user details
const user = await this.get.getOne<User>('users', reimbursement.submitted_by);
if (!user || !user.email) {
console.error('User not found or no email:', reimbursement.submitted_by);
return false;
}
// Get receipt details if needed
let receipts: Receipt[] = [];
if (reimbursement.receipts && reimbursement.receipts.length > 0) {
try {
receipts = await Promise.all(
reimbursement.receipts.map(id => this.get.getOne<Receipt>('receipts', id))
);
} catch (error) {
console.warn('Could not load receipt details:', error);
}
}
const emailData: ReimbursementEmailData = {
user,
reimbursement,
receipts
};
return await this.emailService.sendSubmissionConfirmation(emailData);
} catch (error) {
console.error('Failed to send submission confirmation:', error);
return false;
}
}
/**
* Send specific status-based notifications with custom logic
*/
async notifyByStatus(
reimbursementId: string,
status: string,
previousStatus?: string,
triggeredByUserId?: string,
additionalContext?: Record<string, any>
): Promise<boolean> {
try {
switch (status) {
case 'submitted':
return await this.notifySubmission(reimbursementId);
case 'approved':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'under_review',
status,
triggeredByUserId,
'Your reimbursement has been approved and will be processed for payment.'
);
case 'rejected':
const rejectionReason = additionalContext?.rejectionReason;
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'under_review',
status,
triggeredByUserId,
rejectionReason ? `Rejection reason: ${rejectionReason}` : undefined
);
case 'paid':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'in_progress',
status,
triggeredByUserId,
'Your reimbursement has been completed. Please check your account for the payment.'
);
case 'under_review':
case 'in_progress':
return await this.notifyStatusChange(
reimbursementId,
previousStatus || 'submitted',
status,
triggeredByUserId
);
default:
console.log(`No specific notification handler for status: ${status}`);
return true;
}
} catch (error) {
console.error(`Failed to send notification for status ${status}:`, error);
return false;
}
}
/**
* Batch notify multiple users (for administrative notifications)
*/
async notifyAdmins(
subject: string,
message: string,
reimbursementId?: string
): Promise<boolean> {
try {
// This could be enhanced to get admin user emails from the officers table
// For now, we'll just log this functionality
console.log('Admin notification requested:', { subject, message, reimbursementId });
// TODO: Implement admin notification logic
// - Get list of admin users from officers table
// - Send email to all admins
return true;
} catch (error) {
console.error('Failed to send admin notification:', error);
return false;
}
}
/**
* Test email functionality (useful for development)
*/
async testEmail(userEmail: string): Promise<boolean> {
try {
// Create a test user object
const testUser: User = {
id: 'test-user',
created: new Date().toISOString(),
updated: new Date().toISOString(),
email: userEmail,
emailVisibility: true,
verified: true,
name: 'Test User'
};
// Create a test reimbursement object
const testReimbursement: Reimbursement = {
id: 'test-reimbursement',
created: new Date().toISOString(),
updated: new Date().toISOString(),
title: 'Test Reimbursement',
total_amount: 99.99,
date_of_purchase: new Date().toISOString(),
payment_method: 'Personal Credit Card',
status: 'submitted',
submitted_by: 'test-user',
additional_info: 'This is a test reimbursement for email functionality.',
receipts: [],
department: 'events'
};
const emailData: StatusChangeEmailData = {
user: testUser,
reimbursement: testReimbursement,
previousStatus: 'submitted',
newStatus: 'approved',
changedBy: 'Test Admin',
comment: 'This is a test email notification.'
};
return await this.emailService.sendStatusChangeEmail(emailData);
} catch (error) {
console.error('Failed to send test email:', error);
return false;
}
}
}