diff --git a/backend/package-lock.json b/backend/package-lock.json index fb08404..93810ff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -24,12 +24,14 @@ "get-jwks": "^11.0.3", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "mailparser": "^3.9.3", "node-cron": "^3.0.3", "opossum": "^8.0.0", "pg": "^8.13.1", "pino": "^9.6.0", "resend": "^3.0.0", "stripe": "^20.2.0", + "svix": "^1.85.0", "tar": "^7.4.3", "zod": "^3.24.1" }, @@ -37,6 +39,7 @@ "@eslint/js": "^9.17.0", "@types/jest": "^29.5.10", "@types/js-yaml": "^4.0.9", + "@types/mailparser": "^3.4.6", "@types/node": "^22.0.0", "@types/node-cron": "^3.0.11", "@types/opossum": "^8.0.0", @@ -83,7 +86,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1766,6 +1768,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -1921,6 +1929,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1934,7 +1966,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2061,7 +2092,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2273,6 +2303,17 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz", + "integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.7", + "libqp": "2.1.1" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", @@ -2306,7 +2347,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2773,7 +2813,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3470,6 +3509,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3566,7 +3614,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3900,6 +3947,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -4508,6 +4561,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/helmet": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", @@ -4580,6 +4642,22 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4920,7 +4998,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -5736,6 +5813,42 @@ "node": ">= 0.8.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz", + "integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.6.3", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libmime/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -5780,6 +5893,15 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5827,6 +5949,7 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", + "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -5843,6 +5966,24 @@ "node": "20 || >=22" } }, + "node_modules/mailparser": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz", + "integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.2", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.13", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -6071,6 +6212,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -6419,7 +6569,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -6785,6 +6934,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -7126,6 +7284,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7381,6 +7540,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/steed": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", @@ -7602,6 +7771,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz", + "integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/tar": { "version": "7.5.2", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", @@ -7692,7 +7884,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7700,6 +7891,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7841,7 +8041,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7929,7 +8128,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7962,6 +8160,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/backend/package.json b/backend/package.json index 5a9b8ba..cd5c053 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,19 +34,22 @@ "get-jwks": "^11.0.3", "ioredis": "^5.4.2", "js-yaml": "^4.1.0", + "mailparser": "^3.9.3", "node-cron": "^3.0.3", "opossum": "^8.0.0", "pg": "^8.13.1", + "pino": "^9.6.0", "resend": "^3.0.0", "stripe": "^20.2.0", + "svix": "^1.85.0", "tar": "^7.4.3", - "pino": "^9.6.0", "zod": "^3.24.1" }, "devDependencies": { "@eslint/js": "^9.17.0", "@types/jest": "^29.5.10", "@types/js-yaml": "^4.0.9", + "@types/mailparser": "^3.4.6", "@types/node": "^22.0.0", "@types/node-cron": "^3.0.11", "@types/opossum": "^8.0.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index b944cb5..b2504e8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -35,6 +35,7 @@ import { userImportRoutes } from './features/user-import'; import { ownershipCostsRoutes } from './features/ownership-costs'; import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions'; import { ocrRoutes } from './features/ocr'; +import { emailIngestionWebhookRoutes } from './features/email-ingestion'; import { pool } from './core/config/database'; import { configRoutes } from './core/config/config.routes'; @@ -96,7 +97,7 @@ async function buildApp(): Promise { status: 'healthy', timestamp: new Date().toISOString(), environment: process.env['NODE_ENV'], - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion'] }); }); @@ -106,7 +107,7 @@ async function buildApp(): Promise { status: 'healthy', scope: 'api', timestamp: new Date().toISOString(), - features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr'] + features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion'] }); }); @@ -152,6 +153,7 @@ async function buildApp(): Promise { await app.register(subscriptionsRoutes, { prefix: '/api' }); await app.register(donationsRoutes, { prefix: '/api' }); await app.register(webhooksRoutes, { prefix: '/api' }); + await app.register(emailIngestionWebhookRoutes, { prefix: '/api' }); await app.register(ocrRoutes, { prefix: '/api' }); await app.register(configRoutes, { prefix: '/api' }); diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index f9aa904..30f7ca4 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -126,6 +126,7 @@ const secretsSchema = z.object({ auth0_management_client_secret: z.string(), google_maps_api_key: z.string(), resend_api_key: z.string(), + resend_webhook_secret: z.string().optional(), // Stripe secrets (API keys only - price IDs are config, not secrets) stripe_secret_key: z.string(), stripe_webhook_secret: z.string(), @@ -143,6 +144,10 @@ export interface AppConfiguration { getRedisUrl(): string; getAuth0Config(): { domain: string; audience: string; clientSecret: string }; getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string }; + getResendConfig(): { + apiKey: string; + webhookSecret: string | undefined; + }; getStripeConfig(): { secretKey: string; webhookSecret: string; @@ -185,6 +190,7 @@ class ConfigurationLoader { 'auth0-management-client-secret', 'google-maps-api-key', 'resend-api-key', + 'resend-webhook-secret', 'stripe-secret-key', 'stripe-webhook-secret', ]; @@ -250,6 +256,13 @@ class ConfigurationLoader { }; }, + getResendConfig() { + return { + apiKey: secrets.resend_api_key, + webhookSecret: secrets.resend_webhook_secret, + }; + }, + getStripeConfig() { return { secretKey: secrets.stripe_secret_key, @@ -258,8 +271,11 @@ class ConfigurationLoader { }, }; - // Set RESEND_API_KEY in environment for EmailService + // Set Resend environment variables for EmailService and webhook verification process.env['RESEND_API_KEY'] = secrets.resend_api_key; + if (secrets.resend_webhook_secret) { + process.env['RESEND_WEBHOOK_SECRET'] = secrets.resend_webhook_secret; + } logger.info('Configuration loaded successfully', { configSource: 'yaml', diff --git a/backend/src/features/email-ingestion/api/email-ingestion.controller.ts b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts new file mode 100644 index 0000000..73ac9d3 --- /dev/null +++ b/backend/src/features/email-ingestion/api/email-ingestion.controller.ts @@ -0,0 +1,131 @@ +/** + * @ai-summary Controller for Resend inbound email webhook + * @ai-context Verifies signatures, checks idempotency, queues emails for async processing + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { ResendInboundClient } from '../external/resend-inbound.client'; +import { pool } from '../../../core/config/database'; +import { logger } from '../../../core/logging/logger'; +import type { ResendWebhookEvent } from '../domain/email-ingestion.types'; + +export class EmailIngestionController { + private resendClient: ResendInboundClient; + + constructor() { + this.resendClient = new ResendInboundClient(); + } + + async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise { + try { + const rawBody = (request as any).rawBody; + if (!rawBody) { + logger.error('Missing raw body in Resend webhook request'); + return reply.status(400).send({ error: 'Missing raw body' }); + } + + // Extract Svix headers for signature verification + const headers: Record = { + 'svix-id': (request.headers['svix-id'] as string) || '', + 'svix-timestamp': (request.headers['svix-timestamp'] as string) || '', + 'svix-signature': (request.headers['svix-signature'] as string) || '', + }; + + // Verify webhook signature + let event: ResendWebhookEvent; + try { + event = this.resendClient.verifyWebhookSignature(rawBody, headers); + } catch (error: any) { + logger.warn('Invalid Resend webhook signature', { error: error.message }); + return reply.status(400).send({ error: 'Invalid signature' }); + } + + const emailId = event.data.email_id; + const senderEmail = event.data.from; + + // Idempotency check: reject if email_id already exists in queue + const existing = await pool.query( + 'SELECT id FROM email_ingestion_queue WHERE email_id = $1', + [emailId] + ); + + if (existing.rows.length > 0) { + logger.info('Duplicate email webhook received, skipping', { emailId }); + return reply.status(200).send({ received: true, duplicate: true }); + } + + // Insert queue record with status=pending + // user_id is set to sender_email initially; async processing resolves to Auth0 sub + await pool.query( + `INSERT INTO email_ingestion_queue + (email_id, sender_email, user_id, received_at, subject, status) + VALUES ($1, $2, $3, $4, $5, 'pending')`, + [ + emailId, + senderEmail, + senderEmail, + event.data.created_at || new Date().toISOString(), + event.data.subject, + ] + ); + + logger.info('Inbound email queued for processing', { emailId, senderEmail }); + + // Return 200 immediately before processing begins + reply.status(200).send({ received: true }); + + // Trigger async processing via setImmediate + setImmediate(() => { + this.processEmailAsync(emailId, event).catch((error) => { + logger.error('Async email processing failed', { + emailId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + } catch (error: any) { + logger.error('Resend webhook handler error', { + error: error.message, + stack: error.stack, + }); + return reply.status(500).send({ error: 'Webhook processing failed' }); + } + } + + /** + * Async email processing stub - full implementation in later sub-issue. + * Will handle: sender validation, user lookup, OCR, record creation, notifications. + */ + private async processEmailAsync(emailId: string, event: ResendWebhookEvent): Promise { + try { + await pool.query( + "UPDATE email_ingestion_queue SET status = 'processing' WHERE email_id = $1", + [emailId] + ); + + logger.info('Async email processing started', { + emailId, + subject: event.data.subject, + attachmentCount: event.data.attachments?.length || 0, + }); + + // Full processing pipeline will be implemented in subsequent sub-issues: + // 1. Sender validation (lookup user by email) + // 2. Fetch and parse raw email via ResendInboundClient + // 3. OCR attachments via existing OCR service + // 4. Classify record type (fuel vs maintenance) + // 5. Create record or queue for vehicle association + // 6. Send notification emails + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('Email processing pipeline error', { emailId, error: errorMessage }); + + await pool.query( + "UPDATE email_ingestion_queue SET status = 'failed', error_message = $2 WHERE email_id = $1", + [emailId, errorMessage] + ).catch((dbError) => { + logger.error('Failed to update queue status to failed', { emailId, error: dbError }); + }); + } + } +} diff --git a/backend/src/features/email-ingestion/api/email-ingestion.routes.ts b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts new file mode 100644 index 0000000..6c58683 --- /dev/null +++ b/backend/src/features/email-ingestion/api/email-ingestion.routes.ts @@ -0,0 +1,24 @@ +/** + * @ai-summary Resend inbound webhook route registration + * @ai-context Public endpoint (no JWT auth) with rawBody for signature verification + */ + +import { FastifyPluginAsync } from 'fastify'; +import { EmailIngestionController } from './email-ingestion.controller'; + +export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => { + const controller = new EmailIngestionController(); + + // POST /api/webhooks/resend/inbound - PUBLIC endpoint (no JWT auth) + // Resend authenticates via webhook signature verification (Svix) + // rawBody MUST be enabled for signature verification to work + fastify.post( + '/webhooks/resend/inbound', + { + config: { + rawBody: true, + }, + }, + controller.handleInboundWebhook.bind(controller) + ); +}; diff --git a/backend/src/features/email-ingestion/external/resend-inbound.client.ts b/backend/src/features/email-ingestion/external/resend-inbound.client.ts new file mode 100644 index 0000000..8753d3a --- /dev/null +++ b/backend/src/features/email-ingestion/external/resend-inbound.client.ts @@ -0,0 +1,110 @@ +/** + * @ai-summary Resend inbound email client for webhook verification and email parsing + * @ai-context Verifies Resend webhook signatures via Svix, fetches raw emails, parses with mailparser + */ + +import { Webhook } from 'svix'; +import { simpleParser } from 'mailparser'; +import { logger } from '../../../core/logging/logger'; +import type { ResendWebhookEvent } from '../domain/email-ingestion.types'; + +export interface ParsedEmailResult { + text: string | null; + html: string | null; + attachments: ParsedEmailAttachment[]; +} + +export interface ParsedEmailAttachment { + filename: string; + contentType: string; + content: Buffer; + size: number; +} + +export class ResendInboundClient { + private webhookSecret: string | undefined; + private apiKey: string; + + constructor() { + this.apiKey = process.env['RESEND_API_KEY'] || ''; + this.webhookSecret = process.env['RESEND_WEBHOOK_SECRET']; + } + + /** + * Verify Resend webhook signature using Svix + * @throws Error if signature is invalid or secret is not configured + */ + verifyWebhookSignature(rawBody: string | Buffer, headers: Record): ResendWebhookEvent { + if (!this.webhookSecret) { + throw new Error('RESEND_WEBHOOK_SECRET is not configured'); + } + + const wh = new Webhook(this.webhookSecret); + const verified = wh.verify( + typeof rawBody === 'string' ? rawBody : rawBody.toString(), + { + 'svix-id': headers['svix-id'] || '', + 'svix-timestamp': headers['svix-timestamp'] || '', + 'svix-signature': headers['svix-signature'] || '', + } + ); + + return verified as unknown as ResendWebhookEvent; + } + + /** + * Fetch email metadata from Resend API including raw download URL + */ + async getEmail(emailId: string): Promise<{ downloadUrl: string }> { + const response = await fetch(`https://api.resend.com/emails/${emailId}`, { + headers: { 'Authorization': `Bearer ${this.apiKey}` }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch email ${emailId}: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { raw?: { download_url?: string } }; + const downloadUrl = data.raw?.download_url; + + if (!downloadUrl) { + throw new Error(`No download URL for email ${emailId}`); + } + + logger.info('Fetched email metadata from Resend', { emailId }); + return { downloadUrl }; + } + + /** + * Download raw RFC 5322 email content from Resend download URL + */ + async downloadRawEmail(downloadUrl: string): Promise { + const response = await fetch(downloadUrl); + + if (!response.ok) { + throw new Error(`Failed to download raw email: ${response.status} ${response.statusText}`); + } + + const rawEmail = await response.text(); + logger.info('Downloaded raw email', { size: rawEmail.length }); + return rawEmail; + } + + /** + * Parse raw RFC 5322 email into structured text/html body and attachments + */ + async parseEmail(rawEmail: string): Promise { + const parsed = await simpleParser(rawEmail); + + return { + text: parsed.text || null, + html: typeof parsed.html === 'string' ? parsed.html : null, + attachments: (parsed.attachments || []).map((att) => ({ + filename: att.filename || 'unnamed', + contentType: att.contentType || 'application/octet-stream', + content: att.content, + size: att.size, + })), + }; + } +} diff --git a/backend/src/features/email-ingestion/index.ts b/backend/src/features/email-ingestion/index.ts new file mode 100644 index 0000000..64057bd --- /dev/null +++ b/backend/src/features/email-ingestion/index.ts @@ -0,0 +1,14 @@ +/** + * @ai-summary Email ingestion feature barrel export + * @ai-context Exports webhook routes for Resend inbound email processing + */ + +export { emailIngestionWebhookRoutes } from './api/email-ingestion.routes'; +export { ResendInboundClient } from './external/resend-inbound.client'; +export type { ParsedEmailResult, ParsedEmailAttachment } from './external/resend-inbound.client'; +export type { + EmailIngestionQueueRecord, + EmailIngestionStatus, + ResendWebhookEvent, + ResendWebhookEventData, +} from './domain/email-ingestion.types';