Implemented email notifications.

This commit is contained in:
Tom Butcher 2026-03-01 16:54:56 +00:00
parent a2d62ddec1
commit 78509ed3a2
8 changed files with 676 additions and 45 deletions

View File

@ -46,6 +46,16 @@
"filesBucket": "farmcontrol"
}
},
"smtp": {
"host": "mail.tombutcher.work",
"port": 465,
"secure": true,
"auth": {
"user": "farmcontrol",
"pass": "XwV5u3jWufuo5E5U4N9hBHfNfwk28D7fNdFN"
},
"from": "FarmControl <farmcontrol@tombutcher.work>"
},
"otpExpiryMins": 0.5
},
"test": {
@ -95,6 +105,16 @@
"filesBucket": "farmcontrol-test"
}
},
"smtp": {
"host": "localhost",
"port": 587,
"secure": false,
"auth": {
"user": "",
"pass": ""
},
"from": "FarmControl <farmcontrol@tombutcher.work>"
},
"otpExpiryMins": 0.5
},
"production": {
@ -143,6 +163,16 @@
"region": "us-east-1",
"filesBucket": "farmcontrol"
}
},
"smtp": {
"host": "localhost",
"port": 587,
"secure": false,
"auth": {
"user": "",
"pass": ""
},
"from": "FarmControl <noreply@farmcontrol.app>"
}
}
}

View File

@ -30,6 +30,7 @@
"nodemailer": "*",
"nodemon": "^3.1.11",
"pg": "^8.16.3",
"puppeteer": "^24.37.5",
"redis": "^5.10.0",
"sequelize": "^6.37.7"
},

388
pnpm-lock.yaml generated
View File

@ -86,6 +86,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.18.0
puppeteer:
specifier: ^24.37.5
version: 24.37.5
redis:
specifier: ^5.10.0
version: 5.10.0
@ -1184,6 +1187,11 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@puppeteer/browsers@2.13.0':
resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==}
engines: {node: '>=18'}
hasBin: true
'@redis/bloom@5.10.0':
resolution: {integrity: sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==}
engines: {node: '>= 18'}
@ -1733,6 +1741,17 @@ packages:
axios@1.13.4:
resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==}
axios@1.13.6:
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
b4a@1.8.0:
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
babel-jest@30.2.0:
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@ -1781,6 +1800,44 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
bare-fs@4.5.5:
resolution: {integrity: sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==}
engines: {bare: '>=1.16.0'}
peerDependencies:
bare-buffer: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-os@3.7.0:
resolution: {integrity: sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==}
engines: {bare: '>=1.14.0'}
bare-path@3.0.0:
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
bare-stream@2.8.0:
resolution: {integrity: sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==}
peerDependencies:
bare-buffer: '*'
bare-events: '*'
peerDependenciesMeta:
bare-buffer:
optional: true
bare-events:
optional: true
bare-url@2.3.2:
resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==}
baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
@ -1788,6 +1845,7 @@ packages:
basic-ftp@5.1.0:
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
engines: {node: '>=10.0.0'}
deprecated: Security vulnerability fixed in 5.2.0, please upgrade
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
@ -1898,11 +1956,16 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chromedriver@145.0.0:
resolution: {integrity: sha512-rnqHS3u+OEdhaS3PmV7V8KYHBLiIOrIKMkRZSEaQcQXnpqHQTPBrS/1x7r0MJvuywtv2qFQYNbd5yXUmuxFvmg==}
chromedriver@146.0.0:
resolution: {integrity: sha512-fDAbuEy+Dn9F/h8fphiQIUEyUDOTGlfjZHfI9dJZz75+ui/LIHqWzStQt87vpwA9oV3ut4C2W3flfvbn3KELFQ==}
engines: {node: '>=20'}
hasBin: true
chromium-bidi@14.0.0:
resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==}
peerDependencies:
devtools-protocol: '*'
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
@ -2014,6 +2077,15 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -2111,6 +2183,9 @@ packages:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
devtools-protocol@0.0.1566079:
resolution: {integrity: sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==}
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
@ -2170,6 +2245,10 @@ packages:
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -2404,6 +2483,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@ -2438,6 +2520,9 @@ packages:
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@ -3323,6 +3408,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@ -3733,6 +3821,10 @@ packages:
resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@ -3750,6 +3842,9 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.0.0:
resolution: {integrity: sha512-h2lD3OfRraP3R51rNFKIE8nX+qoLr1mE74X91YhVxtDbt+OD6ntoNZv56+JgI4RCdtwQ5eexsOk1KdOQDfvPCQ==}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@ -3760,6 +3855,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
puppeteer-core@24.37.5:
resolution: {integrity: sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==}
engines: {node: '>=18'}
puppeteer@24.37.5:
resolution: {integrity: sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==}
engines: {node: '>=18'}
hasBin: true
pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
@ -3914,6 +4018,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
@ -4094,6 +4203,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-length@4.0.2:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
@ -4183,13 +4295,25 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
tar-fs@3.1.1:
resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==}
tar-stream@3.1.8:
resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==}
tcp-port-used@1.0.2:
resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==}
teex@1.0.1:
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@ -4272,6 +4396,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typed-query-selector@2.12.1:
resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
@ -4363,6 +4490,9 @@ packages:
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
webdriver-bidi-protocol@0.4.1:
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@ -4414,6 +4544,18 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xdg-basedir@4.0.0:
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
engines: {node: '>=8'}
@ -4455,6 +4597,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@aws-crypto/crc32@5.2.0':
@ -6121,6 +6266,21 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@puppeteer/browsers@2.13.0':
dependencies:
debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
semver: 7.7.4
tar-fs: 3.1.1
yargs: 17.7.2
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
- supports-color
'@redis/bloom@5.10.0(@redis/client@5.10.0)':
dependencies:
'@redis/client': 5.10.0
@ -6494,8 +6654,7 @@ snapshots:
'@testim/chrome-version@1.1.4':
optional: true
'@tootallnate/quickjs-emscripten@0.23.0':
optional: true
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tybys/wasm-util@0.10.1':
dependencies:
@ -6644,8 +6803,7 @@ snapshots:
acorn@8.15.0: {}
agent-base@7.1.4:
optional: true
agent-base@7.1.4: {}
ajv@6.12.6:
dependencies:
@ -6773,7 +6931,6 @@ snapshots:
ast-types@0.13.4:
dependencies:
tslib: 2.8.1
optional: true
async-function@1.0.0: {}
@ -6793,6 +6950,17 @@ snapshots:
transitivePeerDependencies:
- debug
axios@1.13.6:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
optional: true
b4a@1.8.0: {}
babel-jest@30.2.0(@babel/core@7.29.0):
dependencies:
'@babel/core': 7.29.0
@ -6877,10 +7045,42 @@ snapshots:
balanced-match@1.0.2: {}
bare-events@2.8.2: {}
bare-fs@4.5.5:
dependencies:
bare-events: 2.8.2
bare-path: 3.0.0
bare-stream: 2.8.0(bare-events@2.8.2)
bare-url: 2.3.2
fast-fifo: 1.3.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-os@3.7.0: {}
bare-path@3.0.0:
dependencies:
bare-os: 3.7.0
bare-stream@2.8.0(bare-events@2.8.2):
dependencies:
streamx: 2.23.0
teex: 1.0.1
optionalDependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
bare-url@2.3.2:
dependencies:
bare-path: 3.0.0
baseline-browser-mapping@2.9.19: {}
basic-ftp@5.1.0:
optional: true
basic-ftp@5.1.0: {}
bcrypt@6.0.0:
dependencies:
@ -6938,8 +7138,7 @@ snapshots:
bson@6.10.4: {}
buffer-crc32@0.2.13:
optional: true
buffer-crc32@0.2.13: {}
buffer-equal-constant-time@1.0.1: {}
@ -7001,20 +7200,26 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chromedriver@145.0.0:
chromedriver@146.0.0:
dependencies:
'@testim/chrome-version': 1.1.4
axios: 1.13.4
axios: 1.13.6
compare-versions: 6.1.1
extract-zip: 2.0.1
proxy-agent: 6.5.0
proxy-from-env: 1.1.0
proxy-from-env: 2.0.0
tcp-port-used: 1.0.2
transitivePeerDependencies:
- debug
- supports-color
optional: true
chromium-bidi@14.0.0(devtools-protocol@0.0.1566079):
dependencies:
devtools-protocol: 0.0.1566079
mitt: 3.0.1
zod: 3.25.76
ci-info@4.4.0: {}
cjs-module-lexer@2.2.0: {}
@ -7114,14 +7319,20 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig@9.0.0:
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
data-uri-to-buffer@6.0.2:
optional: true
data-uri-to-buffer@6.0.2: {}
data-view-buffer@1.0.2:
dependencies:
@ -7185,7 +7396,6 @@ snapshots:
ast-types: 0.13.4
escodegen: 2.1.0
esprima: 4.0.1
optional: true
delayed-stream@1.0.0: {}
@ -7193,6 +7403,8 @@ snapshots:
detect-newline@3.1.0: {}
devtools-protocol@0.0.1566079: {}
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
@ -7254,7 +7466,8 @@ snapshots:
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
optional: true
env-paths@2.2.1: {}
error-ex@1.3.4:
dependencies:
@ -7378,7 +7591,6 @@ snapshots:
esutils: 2.0.3
optionalDependencies:
source-map: 0.6.1
optional: true
eslint-config-prettier@10.1.8(eslint@9.39.2):
dependencies:
@ -7389,7 +7601,7 @@ snapshots:
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1):
eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-plugin-import: 2.32.0(eslint@8.57.1)
@ -7630,6 +7842,12 @@ snapshots:
etag@1.8.1: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@ -7710,12 +7928,13 @@ snapshots:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
- supports-color
optional: true
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-fifo@1.3.2: {}
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@ -7737,7 +7956,6 @@ snapshots:
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
optional: true
file-entry-cache@6.0.1:
dependencies:
@ -7888,7 +8106,6 @@ snapshots:
get-stream@5.2.0:
dependencies:
pump: 3.0.3
optional: true
get-stream@6.0.1: {}
@ -7905,7 +8122,6 @@ snapshots:
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
optional: true
glob-parent@5.1.2:
dependencies:
@ -8005,7 +8221,6 @@ snapshots:
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
optional: true
https-proxy-agent@7.0.6:
dependencies:
@ -8013,7 +8228,6 @@ snapshots:
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
optional: true
human-signals@2.1.0: {}
@ -8056,8 +8270,7 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
ip-address@10.1.0:
optional: true
ip-address@10.1.0: {}
ip-regex@4.3.0:
optional: true
@ -8664,7 +8877,7 @@ snapshots:
dependencies:
jwk-to-pem: 2.0.7
optionalDependencies:
chromedriver: 145.0.0
chromedriver: 146.0.0
transitivePeerDependencies:
- debug
- supports-color
@ -8745,8 +8958,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lru-cache@7.18.3:
optional: true
lru-cache@7.18.3: {}
make-dir@2.1.0:
dependencies:
@ -8816,6 +9028,8 @@ snapshots:
minipass@7.1.2: {}
mitt@3.0.1: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
@ -8901,8 +9115,7 @@ snapshots:
negotiator@1.0.0: {}
netmask@2.0.2:
optional: true
netmask@2.0.2: {}
node-addon-api@8.5.0: {}
@ -9063,13 +9276,11 @@ snapshots:
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
- supports-color
optional: true
pac-resolver@7.0.1:
dependencies:
degenerator: 5.0.1
netmask: 2.0.2
optional: true
package-json-from-dist@1.0.1: {}
@ -9110,8 +9321,7 @@ snapshots:
path-to-regexp@8.3.0: {}
pend@1.2.0:
optional: true
pend@1.2.0: {}
pg-cloudflare@1.3.0:
optional: true
@ -9197,6 +9407,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 18.3.1
progress@2.0.3: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@ -9222,20 +9434,55 @@ snapshots:
socks-proxy-agent: 8.0.5
transitivePeerDependencies:
- supports-color
optional: true
proxy-from-env@1.1.0: {}
proxy-from-env@2.0.0:
optional: true
pstree.remy@1.1.8: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
optional: true
punycode@2.3.1: {}
puppeteer-core@24.37.5:
dependencies:
'@puppeteer/browsers': 2.13.0
chromium-bidi: 14.0.0(devtools-protocol@0.0.1566079)
debug: 4.4.3(supports-color@5.5.0)
devtools-protocol: 0.0.1566079
typed-query-selector: 2.12.1
webdriver-bidi-protocol: 0.4.1
ws: 8.19.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
- supports-color
- utf-8-validate
puppeteer@24.37.5:
dependencies:
'@puppeteer/browsers': 2.13.0
chromium-bidi: 14.0.0(devtools-protocol@0.0.1566079)
cosmiconfig: 9.0.0
devtools-protocol: 0.0.1566079
puppeteer-core: 24.37.5
typed-query-selector: 2.12.1
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- bufferutil
- react-native-b4a
- supports-color
- typescript
- utf-8-validate
pure-rand@7.0.1: {}
qs@6.14.1:
@ -9401,6 +9648,8 @@ snapshots:
semver@7.7.3: {}
semver@7.7.4: {}
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@5.5.0)
@ -9539,8 +9788,7 @@ snapshots:
slash@3.0.0: {}
smart-buffer@4.2.0:
optional: true
smart-buffer@4.2.0: {}
socks-proxy-agent@8.0.5:
dependencies:
@ -9549,13 +9797,11 @@ snapshots:
socks: 2.8.7
transitivePeerDependencies:
- supports-color
optional: true
socks@2.8.7:
dependencies:
ip-address: 10.1.0
smart-buffer: 4.2.0
optional: true
source-map-support@0.5.13:
dependencies:
@ -9591,7 +9837,7 @@ snapshots:
standard@17.1.2:
dependencies:
eslint: 8.57.1
eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1)
eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)
eslint-config-standard-jsx: 11.0.0(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(eslint@8.57.1)
eslint-plugin-n: 15.7.0(eslint@8.57.1)
@ -9622,6 +9868,15 @@ snapshots:
streamsearch@1.1.0: {}
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-length@4.0.2:
dependencies:
char-regex: 1.0.2
@ -9745,6 +10000,29 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tar-fs@3.1.1:
dependencies:
pump: 3.0.3
tar-stream: 3.1.8
optionalDependencies:
bare-fs: 4.5.5
bare-path: 3.0.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
tar-stream@3.1.8:
dependencies:
b4a: 1.8.0
bare-fs: 4.5.5
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- bare-buffer
- react-native-b4a
tcp-port-used@1.0.2:
dependencies:
debug: 4.3.1
@ -9753,12 +10031,25 @@ snapshots:
- supports-color
optional: true
teex@1.0.1:
dependencies:
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 7.2.3
minimatch: 3.1.2
text-decoder@1.2.7:
dependencies:
b4a: 1.8.0
transitivePeerDependencies:
- react-native-b4a
text-table@0.2.0: {}
tmpl@1.0.5: {}
@ -9846,6 +10137,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typed-query-selector@2.12.1: {}
typedarray@0.0.6: {}
uid-safe@2.1.5:
@ -9942,6 +10235,8 @@ snapshots:
dependencies:
makeerror: 1.0.12
webdriver-bidi-protocol@0.4.1: {}
webidl-conversions@7.0.0: {}
whatwg-url@14.2.0:
@ -10019,6 +10314,8 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
ws@8.19.0: {}
xdg-basedir@4.0.0: {}
xml@1.0.1: {}
@ -10057,6 +10354,7 @@ snapshots:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
optional: true
yocto-queue@0.1.0: {}
zod@3.25.76: {}

View File

@ -53,6 +53,22 @@ function loadConfig() {
);
}
// Ensure smtp config exists and override with env vars if available
if (!envConfig.smtp) {
envConfig.smtp = {};
}
if (process.env.SMTP_HOST) envConfig.smtp.host = process.env.SMTP_HOST;
if (process.env.SMTP_PORT) envConfig.smtp.port = parseInt(process.env.SMTP_PORT, 10);
if (process.env.SMTP_SECURE) envConfig.smtp.secure = process.env.SMTP_SECURE === 'true';
if (process.env.SMTP_USER || process.env.SMTP_PASS) {
envConfig.smtp.auth = {
...(envConfig.smtp.auth || {}),
...(process.env.SMTP_USER && { user: process.env.SMTP_USER }),
...(process.env.SMTP_PASS && { pass: process.env.SMTP_PASS }),
};
}
if (process.env.SMTP_FROM) envConfig.smtp.from = process.env.SMTP_FROM;
return envConfig;
} catch (err) {
console.error('Error loading config:', err);

133
src/mailworker.js Normal file
View File

@ -0,0 +1,133 @@
/**
* Worker thread for sending email notifications asynchronously.
* Receives payloads from the main thread and performs Puppeteer render + nodemailer send.
*/
import { parentPort } from 'worker_threads';
import puppeteer from 'puppeteer';
import nodemailer from 'nodemailer';
import log4js from 'log4js';
import config from './config.js';
const baseUrl = (urlClient) => (urlClient || 'http://localhost:3000').replace(/\/$/, '');
async function fetchAndInlineStyles(html, urlClient) {
const base = baseUrl(urlClient);
const linkMatches = [...html.matchAll(/<link[^>]+>/g)];
const stylesheetLinks = linkMatches
.map((m) => {
const tag = m[0];
if (!/rel=["']stylesheet["']/i.test(tag)) return null;
const hrefMatch = tag.match(/href=["']([^"']+)["']/);
return hrefMatch ? { tag, href: hrefMatch[1] } : null;
})
.filter(Boolean);
let inlined = html;
for (const { tag, href } of stylesheetLinks) {
const url = href.startsWith('http') ? href : `${base}${href.startsWith('/') ? '' : '/'}${href}`;
try {
const res = await fetch(url);
if (res.ok) {
const css = await res.text();
inlined = inlined.replace(tag, `<style>${css}</style>`);
}
} catch (e) {
logger.trace('Could not fetch stylesheet:', url, e.message);
}
}
return inlined;
}
const logger = log4js.getLogger('MailWorker');
logger.level = config.server.logLevel;
async function sendEmail(payload) {
const { email, title, message, type, metadata, smtpConfig, urlClient, createdAt, updatedAt, authCode } =
payload;
if (!email || !smtpConfig?.host) {
logger.warn('Missing email or SMTP config, skipping...');
return;
}
const params = new URLSearchParams({
title: title || '',
message: message || '',
type: type || 'info',
email: email || '',
createdAt: createdAt || new Date(),
updatedAt: updatedAt || new Date(),
metadata: JSON.stringify(metadata || {}),
});
if (authCode) {
params.set('authCode', authCode);
}
const templateUrl = `${baseUrl(urlClient)}/email/notification?${params.toString()}`;
logger.debug('Rendering template...');
logger.trace('Template URL:', templateUrl);
let html = '';
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.goto(templateUrl, { waitUntil: 'networkidle0', timeout: 15000 });
await page.waitForSelector('#email-notification-root[data-rendered="true"]', { timeout: 5000 });
// Wait for Ant Design CSS-in-JS to finish injecting styles
await new Promise((r) => setTimeout(r, 300));
html = await page.evaluate(() => {
const root = document.getElementById('email-notification-root');
if (!root) return document.documentElement.outerHTML;
const origin = document.location.origin;
const styleTags = Array.from(document.querySelectorAll('style'))
.map((s) => s.outerHTML)
.join('\n');
const linkTags = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map((link) => {
const href = link.getAttribute('href');
const abs = href?.startsWith('http')
? href
: `${origin}${href?.startsWith('/') ? '' : '/'}${href || ''}`;
return `<link rel="stylesheet" href="${abs}">`;
})
.join('\n');
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${styleTags}${linkTags}</head><body>${root.outerHTML}</body></html>`;
});
html = await fetchAndInlineStyles(html, urlClient);
} catch (err) {
logger.error('MailWorker: Puppeteer error', err.message);
html = `<div style="font-family:sans-serif;padding:20px"><h2>${title || 'Notification'}</h2><p>${message || ''}</p></div>`;
} finally {
if (browser) await browser.close();
}
const transporter = nodemailer.createTransport({
host: smtpConfig.host,
port: smtpConfig.port || 587,
secure: smtpConfig.secure || false,
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
});
const subject = title ? `${title} - FarmControl` : 'FarmControl Notification';
const mailOptions = {
from: smtpConfig.from || 'FarmControl <noreply@tombutcher.work>',
to: email,
subject: subject,
html,
};
logger.debug('Sending email...');
logger.trace('Mail options:', mailOptions);
const info = await transporter.sendMail(mailOptions);
logger.debug('Email sent successfully.');
}
parentPort.on('message', (payload) => {
sendEmail(payload).catch((err) => {
logger.error('MailWorker: send failed', err.message);
});
});

View File

@ -7,6 +7,7 @@ import { readFileSync } from 'fs';
import { resolve } from 'path';
import NodeCache from 'node-cache';
import jwt from 'jsonwebtoken';
import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js';
const logger = log4js.getLogger('Auth');
logger.level = config.server.logLevel;
@ -144,6 +145,13 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') =>
}
try {
// Check for temporary email render auth code (30s TTL for Puppeteer)
const emailRenderData = getAndConsumeEmailRenderTokenData(code);
if (emailRenderData) {
logger.debug('Exchanged email render auth code for token');
return res.status(200).json(emailRenderData);
}
// If a request for this code is already in progress, wait for it
if (loginTokenRequests.has(code)) {
const tokenData = await loginTokenRequests.get(code);

View File

@ -0,0 +1,58 @@
/**
* Temporary 30-second auth for email render (Puppeteer).
* Isolated from auth.js to avoid circular dependency with utils.js -> auth.js -> keycloak.js -> database.js -> utils.js
*/
import config from '../../config.js';
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import NodeCache from 'node-cache';
import log4js from 'log4js';
const logger = log4js.getLogger('EmailRenderAuth');
logger.level = config.server?.logLevel || 'info';
const EMAIL_RENDER_TTL = 30;
const emailRenderAuthCache = new NodeCache({ stdTTL: EMAIL_RENDER_TTL });
/**
* Creates a temporary auth code for email render (Puppeteer) with 30-second TTL.
* The UI exchanges this code via getLoginToken to establish a brief session for rendering.
* @param {Object} userDoc - User document (must have username, email, _id, etc.)
* @returns {string} authCode to pass in URL query params
*/
export const createEmailRenderAuthCode = (userDoc) => {
const authCode = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + EMAIL_RENDER_TTL * 1000;
const accessToken = jwt.sign(
{ preferred_username: userDoc.username },
config.auth.sessionSecret,
{ expiresIn: EMAIL_RENDER_TTL }
);
const tokenData = {
access_token: accessToken,
expires_at: expiresAt,
_id: userDoc._id,
username: userDoc.username,
email: userDoc.email,
name: userDoc.name,
firstName: userDoc.firstName,
lastName: userDoc.lastName,
};
emailRenderAuthCache.set(authCode, tokenData);
logger.debug(`Created email render auth code (TTL ${EMAIL_RENDER_TTL}s) for user ${userDoc.username}`);
return authCode;
};
/**
* Exchanges an email render auth code for token data. Consumes the code (one-time use).
* @param {string} code - The auth code from URL query
* @returns {Object|null} Token data or null if invalid/expired
*/
export const getAndConsumeEmailRenderTokenData = (code) => {
const data = emailRenderAuthCache.get(code);
if (data) {
emailRenderAuthCache.del(code);
return data;
}
return null;
};

View File

@ -2,6 +2,7 @@ import { mongoose } from 'mongoose';
import { auditLogModel } from './database/schemas/management/auditlog.schema.js';
import { notificationModel } from './database/schemas/misc/notification.schema.js';
import { userNotifierModel } from './database/schemas/misc/usernotifier.schema.js';
import { userModel } from './database/schemas/management/user.schema.js';
import exifr from 'exifr';
import { natsServer } from './database/nats.js';
import log4js from 'log4js';
@ -9,6 +10,12 @@ import config from './config.js';
import crypto from 'crypto';
import canonicalize from 'canonical-json';
import { getModelByName } from './services/misc/model.js';
import { createEmailRenderAuthCode } from './services/misc/emailRenderAuth.js';
import { Worker } from 'worker_threads';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logger = log4js.getLogger('Utils');
logger.level = config.server.logLevel;
@ -552,6 +559,11 @@ async function notfiyObjectUserNotifiers(id, objectType, title, message, type =
const userNotifiers = await userNotifierModel.find({ object: id, objectType: objectType });
for (const userNotifier of userNotifiers) {
await createNotification(userNotifier.user._id, title, message, type, metadata);
console.log('userNotifier.email', userNotifier.email);
if (userNotifier.email == true) {
console.log('sending email');
await sendEmailNotification(userNotifier.user, title, message, type, metadata);
}
}
}
@ -569,6 +581,79 @@ async function createNotification(user, title, message, type = 'info', metadata)
return notification;
}
let mailWorker = null;
function getMailWorker() {
if (!mailWorker) {
const workerPath = path.join(__dirname, 'mailworker.js');
mailWorker = new Worker(workerPath);
mailWorker.on('error', (err) => logger.error('MailWorker error:', err));
mailWorker.on('exit', (code) => {
if (code !== 0) logger.warn('MailWorker exited with code', code);
mailWorker = null;
});
}
return mailWorker;
}
/**
* Sends an email notification asynchronously via a worker thread.
* Renders a React template on the urlClient, captures HTML with Puppeteer, and emails the user.
* Accepts the same input as createNotification: user, title, message, type, metadata.
* Returns immediately; does not wait for the email to be sent.
* @param {ObjectId|Object} user - User ID or user object (must have email)
* @param {string} title - Notification title
* @param {string} message - Notification message
* @param {string} type - Notification type (info, editObject, deleteObject, error, success)
* @param {Object} metadata - Optional metadata object
*/
async function sendEmailNotification(user, title, message, type = 'info', metadata) {
let userDoc = user;
if (user && (mongoose.Types.ObjectId.isValid(user) || user._id)) {
const userId = user._id || user;
userDoc = await userModel.findById(userId).lean();
}
if (!userDoc?.email) {
logger.warn('sendEmailNotification: no email for user', user);
return null;
}
const smtpConfig = config.smtp;
if (!smtpConfig?.host) {
logger.warn('sendEmailNotification: SMTP not configured, skipping email');
return null;
}
const urlClient = config.app?.urlClient || 'http://localhost:3000';
const authCode = createEmailRenderAuthCode(userDoc);
const payload = {
email: userDoc.email,
title,
message,
type,
metadata: metadata || {},
createdAt: new Date(),
updatedAt: new Date(),
authCode,
smtpConfig: {
host: smtpConfig.host,
port: smtpConfig.port || 587,
secure: smtpConfig.secure || false,
auth: smtpConfig.auth?.user ? smtpConfig.auth : undefined,
from: smtpConfig.from || 'FarmControl <noreply@farmcontrol.app>',
},
urlClient,
};
try {
getMailWorker().postMessage(payload);
} catch (err) {
logger.error('sendEmailNotification: failed to post to worker', err.message);
}
return null;
}
function flatternObjectIds(object) {
if (!object || typeof object !== 'object') {
return object;
@ -798,6 +883,8 @@ export {
distributeChildDelete,
distributeChildNew,
notfiyObjectUserNotifiers,
createNotification,
sendEmailNotification,
getFilter, // <-- add here
convertPropertiesString,
getFileMeta,