diff --git a/backend/package.json b/backend/package.json index 4d74157..ed10e12 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,8 @@ "csv-parse": "^6.2.1", "dotenv": "^16.4.5", "express": "^5.2.1", + "google-auth-library": "^10.6.2", + "googleapis": "^171.4.0", "express-rate-limit": "^8.3.1", "ical-generator": "^9.0.0", "jsonwebtoken": "^8.5.1", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 085b572..bb43598 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + google-auth-library: + specifier: ^10.6.2 + version: 10.6.2 + googleapis: + specifier: ^171.4.0 + version: 171.4.0 jsonwebtoken: specifier: ^8.5.1 version: 8.5.1 @@ -1001,6 +1007,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1144,6 +1154,9 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -1159,6 +1172,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1195,6 +1212,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} @@ -1218,6 +1239,14 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1255,6 +1284,22 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.6.2: + resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + googleapis-common@8.0.1: + resolution: {integrity: sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==} + engines: {node: '>=18.0.0'} + + googleapis@171.4.0: + resolution: {integrity: sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1555,6 +1600,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -1729,6 +1777,15 @@ packages: resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} engines: {node: '>=6.0.0'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -2212,6 +2269,9 @@ packages: urijs@1.19.11: resolution: {integrity: sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2242,6 +2302,10 @@ packages: engines: {node: '>= 16'} hasBin: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -3377,6 +3441,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + data-uri-to-buffer@4.0.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3523,6 +3589,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + fast-json-stable-stringify@2.1.0: {} fast-safe-stringify@2.1.1: {} @@ -3537,6 +3605,11 @@ snapshots: fecha@4.2.3: {} + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3578,6 +3651,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -3595,6 +3672,22 @@ snapshots: function-bind@1.1.2: {} + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -3643,6 +3736,36 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + google-auth-library@10.6.2: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.4 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + googleapis-common@8.0.1: + dependencies: + extend: 3.0.2 + gaxios: 7.1.4 + google-auth-library: 10.6.2 + qs: 6.15.0 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + + googleapis@171.4.0: + dependencies: + google-auth-library: 10.6.2 + googleapis-common: 8.0.1 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -4113,6 +4236,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-parse-even-better-errors@2.3.1: {} json5@2.2.3: {} @@ -4264,6 +4391,14 @@ snapshots: dependencies: uuid: 8.3.2 + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} node-releases@2.0.27: {} @@ -4763,6 +4898,8 @@ snapshots: urijs@1.19.11: {} + url-template@2.0.8: {} + util-deprecate@1.0.2: {} uuid@13.0.0: {} @@ -4793,6 +4930,8 @@ snapshots: transitivePeerDependencies: - supports-color + web-streams-polyfill@3.3.3: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 diff --git a/backend/routes/integrations/gmail.js b/backend/routes/integrations/gmail.js deleted file mode 100644 index 8cfc49f..0000000 --- a/backend/routes/integrations/gmail.js +++ /dev/null @@ -1,71 +0,0 @@ -const express = require('express') -const { - getGmailAuthUrl, - exchangeGmailCodeForTokens, - getGmailProfile, - scanGmailSubscriptions, -} = require('../../services/gmail-service') -const { createState, consumeState } = require('../../utils/oauth-state') - -const router = express.Router() - -router.get('/auth', (_req, res) => { - const state = createState() - const url = getGmailAuthUrl(state) - res.redirect(url) -}) - -router.get('/callback', async (req, res, next) => { - try { - const code = req.query.code - const state = req.query.state - - if (!consumeState(state)) { - return res.status(400).json({ error: 'Invalid OAuth state' }) - } - - if (!code) { - return res.status(400).json({ error: 'Missing OAuth code' }) - } - - const tokens = await exchangeGmailCodeForTokens(code) - const profile = await getGmailProfile(tokens) - - return res.json({ - provider: 'gmail', - email: profile.emailAddress, - tokens: { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expiry_date: tokens.expiry_date, - scope: tokens.scope, - token_type: tokens.token_type, - }, - }) - } catch (error) { - return next(error) - } -}) - -router.post('/scan', async (req, res, next) => { - try { - const { accessToken, refreshToken, sinceDays, maxResults } = req.body - - if (!accessToken) { - return res.status(400).json({ error: 'Missing accessToken' }) - } - - const subscriptions = await scanGmailSubscriptions({ - accessToken, - refreshToken, - sinceDays, - maxResults, - }) - - return res.json({ subscriptions }) - } catch (error) { - return next(error) - } -}) - -module.exports = router diff --git a/backend/routes/integrations/gmail.ts b/backend/routes/integrations/gmail.ts new file mode 100644 index 0000000..64be53c --- /dev/null +++ b/backend/routes/integrations/gmail.ts @@ -0,0 +1,126 @@ +import { Router, Response, NextFunction } from 'express' +import { + getGmailAuthUrl, + exchangeGmailCodeForTokens, + getGmailProfile, + scanGmailSubscriptions, +} from '../../services/gmail-service' +import { createState, consumeState } from '../../utils/oauth-state' +import { supabase } from '../../src/config/database' +import { AuthenticatedRequest } from '../../src/middleware/auth' + +const router: Router = Router() + +// GET /api/integrations/gmail/auth +// Redirect user to Google's consent screen +router.get('/auth', (_req: AuthenticatedRequest, res: Response) => { + const state = createState() + const url = getGmailAuthUrl(state) + res.redirect(url) +}) + +// GET /api/integrations/gmail/callback +// Google redirects here after the user grants permission; saves tokens to email_accounts +router.get('/callback', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const code = req.query.code as string | undefined + const state = req.query.state as string | undefined + + if (!consumeState(state)) { + return res.status(400).json({ error: 'Invalid OAuth state' }) + } + + if (!code) { + return res.status(400).json({ error: 'Missing OAuth code' }) + } + + const tokens = await exchangeGmailCodeForTokens(code) + const profile = await getGmailProfile(tokens) + + const { error: dbError } = await supabase + .from('email_accounts') + .upsert( + { + user_id: req.user!.id, + provider: 'gmail', + email: profile.emailAddress, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + token_expiry: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,provider,email' }, + ) + + if (dbError) throw dbError + + return res.json({ + provider: 'gmail', + email: profile.emailAddress, + tokens: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expiry_date: tokens.expiry_date, + scope: tokens.scope, + token_type: tokens.token_type, + }, + }) + } catch (error) { + return next(error) + } +}) + +// POST /api/integrations/gmail/scan +// Trigger email scan and return detected subscriptions +router.post('/scan', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { accessToken, refreshToken, sinceDays, maxResults } = req.body as { + accessToken?: string + refreshToken?: string + sinceDays?: number + maxResults?: number + } + + if (!accessToken) { + return res.status(400).json({ error: 'Missing accessToken' }) + } + + const subscriptions = await scanGmailSubscriptions({ + accessToken, + refreshToken, + sinceDays, + maxResults, + }) + + return res.json({ subscriptions }) + } catch (error) { + return next(error) + } +}) + +// DELETE /api/integrations/gmail/:id +// Disconnect a Gmail account +router.delete('/:id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + + const { error, count } = await supabase + .from('email_accounts') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('user_id', req.user!.id) + .eq('provider', 'gmail') + + if (error) throw error + + if (!count || count === 0) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json({ success: true }) + } catch (error) { + return next(error) + } +}) + +export default router \ No newline at end of file diff --git a/backend/routes/integrations/outlook.js b/backend/routes/integrations/outlook.js deleted file mode 100644 index fa0653e..0000000 --- a/backend/routes/integrations/outlook.js +++ /dev/null @@ -1,73 +0,0 @@ -const express = require('express') -const { - getOutlookAuthUrl, - exchangeOutlookCodeForTokens, - getOutlookProfile, - scanOutlookSubscriptions, -} = require('../../services/outlook-service') -const { createState, consumeState } = require('../../utils/oauth-state') - -const router = express.Router() - -router.get('/auth', (_req, res) => { - const state = createState() - const url = getOutlookAuthUrl(state) - res.redirect(url) -}) - -router.get('/callback', async (req, res, next) => { - try { - const code = req.query.code - const state = req.query.state - - if (!consumeState(state)) { - return res.status(400).json({ error: 'Invalid OAuth state' }) - } - - if (!code) { - return res.status(400).json({ error: 'Missing OAuth code' }) - } - - const tokens = await exchangeOutlookCodeForTokens(code) - const profile = await getOutlookProfile(tokens.access_token) - - const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString() - - return res.json({ - provider: 'outlook', - email: profile.mail || profile.userPrincipalName, - tokens: { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: expiresAt, - scope: tokens.scope, - token_type: tokens.token_type, - }, - }) - } catch (error) { - return next(error) - } -}) - -router.post('/scan', async (req, res, next) => { - try { - const { accessToken, refreshToken, expiresAt, maxResults } = req.body - - if (!accessToken) { - return res.status(400).json({ error: 'Missing accessToken' }) - } - - const subscriptions = await scanOutlookSubscriptions({ - accessToken, - refreshToken, - expiresAt, - maxResults, - }) - - return res.json({ subscriptions }) - } catch (error) { - return next(error) - } -}) - -module.exports = router diff --git a/backend/routes/integrations/outlook.ts b/backend/routes/integrations/outlook.ts new file mode 100644 index 0000000..ea2c23f --- /dev/null +++ b/backend/routes/integrations/outlook.ts @@ -0,0 +1,129 @@ +import { Router, Response, NextFunction } from 'express' +import { + getOutlookAuthUrl, + exchangeOutlookCodeForTokens, + getOutlookProfile, + scanOutlookSubscriptions, +} from '../../services/outlook-service' +import { createState, consumeState } from '../../utils/oauth-state' +import { supabase } from '../../src/config/database' +import { AuthenticatedRequest } from '../../src/middleware/auth' + +const router: Router = Router() + +// GET /api/integrations/outlook/auth +// Redirect user to Microsoft's consent screen +router.get('/auth', (_req: AuthenticatedRequest, res: Response) => { + const state = createState() + const url = getOutlookAuthUrl(state) + res.redirect(url) +}) + +// GET /api/integrations/outlook/callback +// Microsoft redirects here after the user grants permission; saves tokens to email_accounts +router.get('/callback', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const code = req.query.code as string | undefined + const state = req.query.state as string | undefined + + if (!consumeState(state)) { + return res.status(400).json({ error: 'Invalid OAuth state' }) + } + + if (!code) { + return res.status(400).json({ error: 'Missing OAuth code' }) + } + + const tokens = await exchangeOutlookCodeForTokens(code) + const profile = await getOutlookProfile(tokens.access_token) + + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString() + const email: string = profile.mail || profile.userPrincipalName + + const { error: dbError } = await supabase + .from('email_accounts') + .upsert( + { + user_id: req.user!.id, + provider: 'outlook', + email, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token ?? null, + token_expiry: expiresAt, + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,provider,email' }, + ) + + if (dbError) throw dbError + + return res.json({ + provider: 'outlook', + email, + tokens: { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at: expiresAt, + scope: tokens.scope, + token_type: tokens.token_type, + }, + }) + } catch (error) { + return next(error) + } +}) + +// POST /api/integrations/outlook/scan +// Trigger email scan and return detected subscriptions +router.post('/scan', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { accessToken, refreshToken, expiresAt, maxResults } = req.body as { + accessToken?: string + refreshToken?: string + expiresAt?: string + maxResults?: number + } + + if (!accessToken) { + return res.status(400).json({ error: 'Missing accessToken' }) + } + + const subscriptions = await scanOutlookSubscriptions({ + accessToken, + refreshToken, + expiresAt, + maxResults, + }) + + return res.json({ subscriptions }) + } catch (error) { + return next(error) + } +}) + +// DELETE /api/integrations/outlook/:id +// Disconnect an Outlook account +router.delete('/:id', async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + + const { error, count } = await supabase + .from('email_accounts') + .delete({ count: 'exact' }) + .eq('id', id) + .eq('user_id', req.user!.id) + .eq('provider', 'outlook') + + if (error) throw error + + if (!count || count === 0) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json({ success: true }) + } catch (error) { + return next(error) + } +}) + +export default router \ No newline at end of file diff --git a/backend/services/email-parser.js b/backend/services/email-parser.js deleted file mode 100644 index 6d7df1d..0000000 --- a/backend/services/email-parser.js +++ /dev/null @@ -1,159 +0,0 @@ -const SUBSCRIPTION_KEYWORDS = [ - 'subscription', - 'renewal', - 'auto-renew', - 'billing', - 'billed', - 'charged', - 'invoice', - 'receipt', - 'membership', - 'trial', - 'plan', -] - -const STRONG_PHRASES = [ - 'your subscription', - 'subscription confirmed', - 'trial ends', - 'renews on', - 'payment received', -] - -const INTERVAL_MATCHERS = [ - { pattern: /\bmonthly\b|\bper month\b|\/month\b/i, value: 'monthly' }, - { pattern: /\bannual\b|\byearly\b|\bper year\b|\/year\b/i, value: 'yearly' }, - { pattern: /\bweekly\b|\bper week\b|\/week\b/i, value: 'weekly' }, - { pattern: /\bquarterly\b|\bper quarter\b|\/quarter\b/i, value: 'quarterly' }, -] - -function parseSubscriptionEmail({ subject, from, body }) { - const combined = `${subject || ''}\n${body || ''}`.trim() - const normalized = normalizeText(combined) - const signals = SUBSCRIPTION_KEYWORDS.filter((keyword) => - normalized.includes(keyword) - ) - const strongSignal = STRONG_PHRASES.some((phrase) => normalized.includes(phrase)) - - const { amount, currency } = extractAmount(normalized) - const interval = detectInterval(normalized) - const name = extractSenderName(from) - - if (!signals.length && !strongSignal) { - return null - } - - if (!amount && !strongSignal && !interval) { - return null - } - - let confidence = 0.2 - if (signals.length) confidence += 0.2 - if (strongSignal) confidence += 0.2 - if (amount) confidence += 0.2 - if (interval) confidence += 0.1 - confidence = Math.min(confidence, 0.95) - - return { - name, - amount, - currency, - interval, - signals, - confidence, - } -} - -function normalizeText(text) { - return text - .replace(/\s+/g, ' ') - .replace(/[^\S\r\n]+/g, ' ') - .toLowerCase() -} - -function extractSenderName(from) { - if (!from) { - return null - } - - const trimmed = String(from).trim() - const nameMatch = trimmed.match(/^(.*?)(<|$)/) - if (nameMatch && nameMatch[1]) { - const name = nameMatch[1].replace(/"|'/g, '').trim() - if (name) { - return name - } - } - - const emailMatch = trimmed.match(/([^\s@]+)@/) - if (emailMatch) { - return emailMatch[1] - } - - return trimmed -} - -function extractAmount(text) { - const symbolMatch = text.match(/([$€£])\s?(\d{1,5}(?:[.,]\d{2})?)/i) - if (symbolMatch) { - return { - amount: normalizeAmount(symbolMatch[2]), - currency: symbolToCurrency(symbolMatch[1]), - } - } - - const codeBeforeMatch = text.match( - /\b(USD|EUR|GBP|CAD|AUD)\s?(\d{1,5}(?:[.,]\d{2})?)\b/i - ) - if (codeBeforeMatch) { - return { - amount: normalizeAmount(codeBeforeMatch[2]), - currency: codeBeforeMatch[1].toUpperCase(), - } - } - - const codeAfterMatch = text.match( - /(\d{1,5}(?:[.,]\d{2})?)\s?(USD|EUR|GBP|CAD|AUD)\b/i - ) - if (codeAfterMatch) { - return { - amount: normalizeAmount(codeAfterMatch[1]), - currency: codeAfterMatch[2].toUpperCase(), - } - } - - return { amount: null, currency: null } -} - -function normalizeAmount(value) { - if (!value) { - return null - } - const normalized = String(value).replace(/,/g, '') - const amount = Number.parseFloat(normalized) - return Number.isFinite(amount) ? amount : null -} - -function symbolToCurrency(symbol) { - switch (symbol) { - case '€': - return 'EUR' - case '£': - return 'GBP' - default: - return 'USD' - } -} - -function detectInterval(text) { - for (const matcher of INTERVAL_MATCHERS) { - if (matcher.pattern.test(text)) { - return matcher.value - } - } - return null -} - -module.exports = { - parseSubscriptionEmail, -} diff --git a/backend/services/email-parser.ts b/backend/services/email-parser.ts new file mode 100644 index 0000000..d6e3bca --- /dev/null +++ b/backend/services/email-parser.ts @@ -0,0 +1,163 @@ +// ── Types ───────────────────────────────────────────────────────────────────── + +interface ParseEmailInput { + subject?: string | null + from?: string | null + body?: string | null +} + +interface ParsedSubscription { + name: string | null + amount: number | null + currency: string | null + interval: string | null + signals: string[] + confidence: number +} + +interface ExtractedAmount { + amount: number | null + currency: string | null +} + +interface IntervalMatcher { + pattern: RegExp + value: string +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const SUBSCRIPTION_KEYWORDS: string[] = [ + 'subscription', + 'renewal', + 'auto-renew', + 'billing', + 'billed', + 'charged', + 'invoice', + 'receipt', + 'membership', + 'trial', + 'plan', +] + +const STRONG_PHRASES: string[] = [ + 'your subscription', + 'subscription confirmed', + 'trial ends', + 'renews on', + 'payment received', +] + +const INTERVAL_MATCHERS: IntervalMatcher[] = [ + { pattern: /\bmonthly\b|\bper month\b|\/month\b/i, value: 'monthly' }, + { pattern: /\bannual\b|\byearly\b|\bper year\b|\/year\b/i, value: 'yearly' }, + { pattern: /\bweekly\b|\bper week\b|\/week\b/i, value: 'weekly' }, + { pattern: /\bquarterly\b|\bper quarter\b|\/quarter\b/i, value: 'quarterly' }, +] + +// ── Exported function ───────────────────────────────────────────────────────── + +export function parseSubscriptionEmail({ + subject, + from, + body, +}: ParseEmailInput): ParsedSubscription | null { + const combined = `${subject ?? ''}\n${body ?? ''}`.trim() + const normalized = normalizeText(combined) + + const signals = SUBSCRIPTION_KEYWORDS.filter((keyword) => normalized.includes(keyword)) + const strongSignal = STRONG_PHRASES.some((phrase) => normalized.includes(phrase)) + + const { amount, currency } = extractAmount(normalized) + const interval = detectInterval(normalized) + const name = extractSenderName(from) + + if (!signals.length && !strongSignal) return null + if (!amount && !strongSignal && !interval) return null + + let confidence = 0.2 + if (signals.length) confidence += 0.2 + if (strongSignal) confidence += 0.2 + if (amount) confidence += 0.2 + if (interval) confidence += 0.1 + confidence = Math.min(confidence, 0.95) + + return { name, amount, currency, interval, signals, confidence } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +function normalizeText(text: string): string { + return text + .replace(/\s+/g, ' ') + .replace(/[^\S\r\n]+/g, ' ') + .toLowerCase() +} + +function extractSenderName(from?: string | null): string | null { + if (!from) return null + + const trimmed = String(from).trim() + + const nameMatch = trimmed.match(/^(.*?)(<|$)/) + if (nameMatch?.[1]) { + const name = nameMatch[1].replace(/"|'/g, '').trim() + if (name) return name + } + + const emailMatch = trimmed.match(/([^\s@]+)@/) + if (emailMatch) return emailMatch[1] + + return trimmed +} + +function extractAmount(text: string): ExtractedAmount { + const symbolMatch = text.match(/([$€£])\s?(\d{1,5}(?:[.,]\d{2})?)/i) + if (symbolMatch) { + return { + amount: normalizeAmount(symbolMatch[2]), + currency: symbolToCurrency(symbolMatch[1]), + } + } + + const codeBeforeMatch = text.match(/\b(USD|EUR|GBP|CAD|AUD)\s?(\d{1,5}(?:[.,]\d{2})?)\b/i) + if (codeBeforeMatch) { + return { + amount: normalizeAmount(codeBeforeMatch[2]), + currency: codeBeforeMatch[1].toUpperCase(), + } + } + + const codeAfterMatch = text.match(/(\d{1,5}(?:[.,]\d{2})?)\s?(USD|EUR|GBP|CAD|AUD)\b/i) + if (codeAfterMatch) { + return { + amount: normalizeAmount(codeAfterMatch[1]), + currency: codeAfterMatch[2].toUpperCase(), + } + } + + return { amount: null, currency: null } +} + +function normalizeAmount(value?: string | null): number | null { + if (!value) return null + const normalized = String(value).replace(/,/g, '') + const amount = Number.parseFloat(normalized) + return Number.isFinite(amount) ? amount : null +} + +function symbolToCurrency(symbol: string): string { + switch (symbol) { + case '€': return 'EUR' + case '£': return 'GBP' + default: return 'USD' + } +} + +function detectInterval(text: string): string | null { + for (const matcher of INTERVAL_MATCHERS) { + if (matcher.pattern.test(text)) return matcher.value + } + return null +} \ No newline at end of file diff --git a/backend/services/gmail-service.js b/backend/services/gmail-service.ts similarity index 52% rename from backend/services/gmail-service.js rename to backend/services/gmail-service.ts index 76e94c7..1c342e6 100644 --- a/backend/services/gmail-service.js +++ b/backend/services/gmail-service.ts @@ -1,8 +1,10 @@ -const { google } = require('googleapis') -const { parseSubscriptionEmail } = require('./email-parser') -const { generateProofHash, hashContent } = require('../utils/proof-hashing') +import { google } from 'googleapis' +import type { Credentials } from 'google-auth-library' +import { parseSubscriptionEmail } from './email-parser' +import { generateProofHash, hashContent } from '../utils/proof-hashing' const GMAIL_SCOPES = ['https://www.googleapis.com/auth/gmail.readonly'] + const KEYWORDS = [ 'subscription', 'renewal', @@ -15,6 +17,29 @@ const KEYWORDS = [ 'plan', ] +// ── Types ───────────────────────────────────────────────────────────────────── + +interface ScanGmailOptions { + accessToken: string + refreshToken?: string + sinceDays?: number + maxResults?: number +} + +interface GmailHeader { + name: string + value: string +} + +interface GmailPayload { + mimeType?: string + headers?: GmailHeader[] + body?: { data?: string } + parts?: GmailPayload[] +} + +// ── OAuth client factory ────────────────────────────────────────────────────── + function createOAuthClient() { if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { throw new Error('Missing Google OAuth environment variables') @@ -22,11 +47,13 @@ function createOAuthClient() { return new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, - process.env.GOOGLE_REDIRECT_URI + process.env.GOOGLE_REDIRECT_URI, ) } -function getGmailAuthUrl(state) { +// ── Exported service functions ──────────────────────────────────────────────── + +export function getGmailAuthUrl(state: string): string { const oauth2Client = createOAuthClient() return oauth2Client.generateAuthUrl({ access_type: 'offline', @@ -36,13 +63,13 @@ function getGmailAuthUrl(state) { }) } -async function exchangeGmailCodeForTokens(code) { +export async function exchangeGmailCodeForTokens(code: string): Promise { const oauth2Client = createOAuthClient() const { tokens } = await oauth2Client.getToken(code) return tokens } -async function getGmailProfile(tokens) { +export async function getGmailProfile(tokens: Credentials) { const oauth2Client = createOAuthClient() oauth2Client.setCredentials(tokens) const gmail = google.gmail({ version: 'v1', auth: oauth2Client }) @@ -50,7 +77,12 @@ async function getGmailProfile(tokens) { return profile.data } -async function scanGmailSubscriptions({ accessToken, refreshToken, sinceDays = 120, maxResults = 50 }) { +export async function scanGmailSubscriptions({ + accessToken, + refreshToken, + sinceDays = 120, + maxResults = 50, +}: ScanGmailOptions) { const oauth2Client = createOAuthClient() oauth2Client.setCredentials({ access_token: accessToken, @@ -59,37 +91,40 @@ async function scanGmailSubscriptions({ accessToken, refreshToken, sinceDays = 1 const gmail = google.gmail({ version: 'v1', auth: oauth2Client }) const query = buildQuery(sinceDays) + const listResponse = await gmail.users.messages.list({ userId: 'me', q: query, maxResults, }) - const messages = listResponse.data.messages || [] + const messages = listResponse.data.messages ?? [] const results = [] for (const message of messages) { const details = await gmail.users.messages.get({ userId: 'me', - id: message.id, + id: message.id!, format: 'full', }) - const payload = details.data.payload || {} - const headers = payload.headers || [] + const payload = (details.data.payload ?? {}) as GmailPayload + const headers = payload.headers ?? [] const subject = findHeader(headers, 'Subject') const from = findHeader(headers, 'From') - const receivedAt = findHeader(headers, 'Date') || new Date(details.data.internalDate || Date.now()).toISOString() - let body = extractTextFromPayload(payload) + const receivedAt = + findHeader(headers, 'Date') ?? + new Date(Number(details.data.internalDate) || Date.now()).toISOString() + + let body: string | null = extractTextFromPayload(payload) const parsed = parseSubscriptionEmail({ subject, from, body }) - if (!parsed) { - continue - } + if (!parsed) continue const contentHash = hashContent(body) - // Discard raw email content after hashing/parsing. + // Discard raw email content after hashing/parsing body = null + const proofHash = generateProofHash({ provider: 'gmail', messageId: details.data.id, @@ -121,24 +156,24 @@ async function scanGmailSubscriptions({ accessToken, refreshToken, sinceDays = 1 return results } -function buildQuery(sinceDays) { +// ── Internal helpers ────────────────────────────────────────────────────────── + +function buildQuery(sinceDays: number): string { const keywordQuery = KEYWORDS.map((keyword) => `"${keyword}"`).join(' OR ') const baseQuery = `(${keywordQuery})` - if (!sinceDays) { - return baseQuery - } + if (!sinceDays) return baseQuery return `${baseQuery} newer_than:${sinceDays}d` } -function findHeader(headers, name) { - const match = headers.find((header) => header.name.toLowerCase() === name.toLowerCase()) - return match ? match.value : null +function findHeader(headers: GmailHeader[], name: string): string { + const match = headers.find((h) => h.name.toLowerCase() === name.toLowerCase()) + return match ? match.value : '' } -function extractTextFromPayload(payload) { +function extractTextFromPayload(payload: GmailPayload): string { const parts = collectParts(payload) - const plainParts = parts.filter((part) => part.mimeType === 'text/plain') - const htmlParts = parts.filter((part) => part.mimeType === 'text/html') + const plainParts = parts.filter((p) => p.mimeType === 'text/plain') + const htmlParts = parts.filter((p) => p.mimeType === 'text/html') const sources = plainParts.length ? plainParts : htmlParts const decoded = sources @@ -146,15 +181,13 @@ function extractTextFromPayload(payload) { .filter(Boolean) .join('\n') - if (plainParts.length) { - return decoded - } + if (plainParts.length) return decoded return decoded.replace(/<[^>]+>/g, ' ') } -function collectParts(payload) { - const parts = [] +function collectParts(payload: GmailPayload): GmailPayload[] { + const parts: GmailPayload[] = [] if (payload?.mimeType && payload.body?.data) { parts.push(payload) } @@ -166,17 +199,8 @@ function collectParts(payload) { return parts } -function decodeBase64(data) { - if (!data) { - return '' - } +function decodeBase64(data?: string): string { + if (!data) return '' const normalized = data.replace(/-/g, '+').replace(/_/g, '/') return Buffer.from(normalized, 'base64').toString('utf8') -} - -module.exports = { - getGmailAuthUrl, - exchangeGmailCodeForTokens, - getGmailProfile, - scanGmailSubscriptions, -} +} \ No newline at end of file diff --git a/backend/services/outlook-service.js b/backend/services/outlook-service.js deleted file mode 100644 index ff2614b..0000000 --- a/backend/services/outlook-service.js +++ /dev/null @@ -1,171 +0,0 @@ -const { parseSubscriptionEmail } = require('./email-parser') -const { generateProofHash, hashContent } = require('../utils/proof-hashing') - -const OUTLOOK_SCOPES = ['offline_access', 'User.Read', 'Mail.Read'] -const KEYWORDS = [ - 'subscription', - 'renewal', - 'invoice', - 'receipt', - 'billing', - 'charged', - 'trial', - 'membership', - 'plan', -] - -function getOutlookAuthUrl(state) { - const params = new URLSearchParams({ - client_id: process.env.MICROSOFT_CLIENT_ID || '', - response_type: 'code', - redirect_uri: process.env.MICROSOFT_REDIRECT_URI || '', - response_mode: 'query', - scope: OUTLOOK_SCOPES.join(' '), - prompt: 'consent', - }) - - if (state) { - params.set('state', state) - } - - return `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID || 'common'}/oauth2/v2.0/authorize?${params.toString()}` -} - -async function exchangeOutlookCodeForTokens(code) { - return requestOutlookToken({ - code, - grant_type: 'authorization_code', - redirect_uri: process.env.MICROSOFT_REDIRECT_URI || '', - }) -} - -async function refreshOutlookToken(refreshToken) { - return requestOutlookToken({ - refresh_token: refreshToken, - grant_type: 'refresh_token', - }) -} - -async function requestOutlookToken(params) { - const body = new URLSearchParams({ - client_id: process.env.MICROSOFT_CLIENT_ID || '', - client_secret: process.env.MICROSOFT_CLIENT_SECRET || '', - ...params, - }) - - const response = await fetch( - `https://login.microsoftonline.com/${process.env.MICROSOFT_TENANT_ID || 'common'}/oauth2/v2.0/token`, - { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - } - ) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Outlook token exchange failed: ${error}`) - } - - return response.json() -} - -async function getOutlookProfile(accessToken) { - const response = await fetch('https://graph.microsoft.com/v1.0/me', { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Outlook profile fetch failed: ${error}`) - } - - return response.json() -} - -async function scanOutlookSubscriptions({ - accessToken, - refreshToken, - expiresAt, - maxResults = 50, -}) { - let token = accessToken - - if (expiresAt && refreshToken && new Date(expiresAt) <= new Date()) { - const refreshed = await refreshOutlookToken(refreshToken) - token = refreshed.access_token - } - - const searchQuery = KEYWORDS.join(' OR ') - const url = new URL('https://graph.microsoft.com/v1.0/me/messages') - url.searchParams.set('$search', `"${searchQuery}"`) - url.searchParams.set('$select', 'id,subject,from,receivedDateTime,body') - url.searchParams.set('$top', String(maxResults)) - - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${token}`, - ConsistencyLevel: 'eventual', - Prefer: 'outlook.body-content-type="text"', - }, - }) - - if (!response.ok) { - const error = await response.text() - throw new Error(`Outlook message scan failed: ${error}`) - } - - const data = await response.json() - const results = [] - - for (const message of data.value || []) { - const subject = message.subject || null - const from = message.from?.emailAddress?.name || message.from?.emailAddress?.address || null - const receivedAt = message.receivedDateTime || null - let body = message.body?.content || '' - - const parsed = parseSubscriptionEmail({ subject, from, body }) - if (!parsed) { - continue - } - - const contentHash = hashContent(body) - // Discard raw email content after hashing/parsing. - body = null - const proofHash = generateProofHash({ - provider: 'outlook', - messageId: message.id, - receivedAt, - subject, - from, - amount: parsed.amount, - currency: parsed.currency, - interval: parsed.interval, - contentHash, - }) - - results.push({ - provider: 'outlook', - messageId: message.id, - receivedAt, - subject, - from, - ...parsed, - proof: { - hash: proofHash, - contentHash, - algorithm: 'sha256', - }, - }) - } - - return results -} - -module.exports = { - getOutlookAuthUrl, - exchangeOutlookCodeForTokens, - getOutlookProfile, - refreshOutlookToken, - scanOutlookSubscriptions, -} diff --git a/backend/services/outlook-service.ts b/backend/services/outlook-service.ts new file mode 100644 index 0000000..38c5e15 --- /dev/null +++ b/backend/services/outlook-service.ts @@ -0,0 +1,197 @@ +import { parseSubscriptionEmail } from './email-parser' +import { generateProofHash, hashContent } from '../utils/proof-hashing' + +const OUTLOOK_SCOPES = ['offline_access', 'User.Read', 'Mail.Read'] + +const KEYWORDS = [ + 'subscription', + 'renewal', + 'invoice', + 'receipt', + 'billing', + 'charged', + 'trial', + 'membership', + 'plan', +] + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface OutlookTokenResponse { + access_token: string + refresh_token?: string + expires_in: number + scope: string + token_type: string +} + +interface OutlookProfile { + mail?: string + userPrincipalName: string + displayName?: string +} + +interface ScanOutlookOptions { + accessToken: string + refreshToken?: string + expiresAt?: string + maxResults?: number +} + +interface TokenRequestParams { + grant_type: string + code?: string + redirect_uri?: string + refresh_token?: string +} + +// ── Exported functions ──────────────────────────────────────────────────────── + +export function getOutlookAuthUrl(state?: string): string { + const params = new URLSearchParams({ + client_id: process.env.MICROSOFT_CLIENT_ID ?? '', + response_type: 'code', + redirect_uri: process.env.MICROSOFT_REDIRECT_URI ?? '', + response_mode: 'query', + scope: OUTLOOK_SCOPES.join(' '), + prompt: 'consent', + }) + + if (state) params.set('state', state) + + const tenant = process.env.MICROSOFT_TENANT_ID ?? 'common' + return `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?${params.toString()}` +} + +export async function exchangeOutlookCodeForTokens(code: string): Promise { + return requestOutlookToken({ + code, + grant_type: 'authorization_code', + redirect_uri: process.env.MICROSOFT_REDIRECT_URI ?? '', + }) +} + +export async function refreshOutlookToken(refreshToken: string): Promise { + return requestOutlookToken({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + }) +} + +export async function getOutlookProfile(accessToken: string): Promise { + const response = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Outlook profile fetch failed: ${error}`) + } + + return response.json() as Promise +} + +export async function scanOutlookSubscriptions({ + accessToken, + refreshToken, + expiresAt, + maxResults = 50, +}: ScanOutlookOptions) { + let token = accessToken + + if (expiresAt && refreshToken && new Date(expiresAt) <= new Date()) { + const refreshed = await refreshOutlookToken(refreshToken) + token = refreshed.access_token + } + + const searchQuery = KEYWORDS.join(' OR ') + const url = new URL('https://graph.microsoft.com/v1.0/me/messages') + url.searchParams.set('$search', `"${searchQuery}"`) + url.searchParams.set('$select', 'id,subject,from,receivedDateTime,body') + url.searchParams.set('$top', String(maxResults)) + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + ConsistencyLevel: 'eventual', + Prefer: 'outlook.body-content-type="text"', + }, + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Outlook message scan failed: ${error}`) + } + + const data = await response.json() as { value?: any[] } + const results = [] + + for (const message of data.value ?? []) { + const subject = message.subject ?? null + const from = message.from?.emailAddress?.name ?? message.from?.emailAddress?.address ?? null + const receivedAt = message.receivedDateTime ?? null + let body: string | null = message.body?.content ?? '' + + const parsed = parseSubscriptionEmail({ subject, from, body }) + if (!parsed) continue + + const contentHash = hashContent(body) + // Discard raw email content after hashing/parsing + body = null + + const proofHash = generateProofHash({ + provider: 'outlook', + messageId: message.id, + receivedAt, + subject, + from, + amount: parsed.amount, + currency: parsed.currency, + interval: parsed.interval, + contentHash, + }) + + results.push({ + provider: 'outlook', + messageId: message.id, + receivedAt, + subject, + from, + ...parsed, + proof: { + hash: proofHash, + contentHash, + algorithm: 'sha256', + }, + }) + } + + return results +} + +// ── Internal helper ─────────────────────────────────────────────────────────── + +async function requestOutlookToken(params: TokenRequestParams): Promise { + const body = new URLSearchParams({ + client_id: process.env.MICROSOFT_CLIENT_ID ?? '', + client_secret: process.env.MICROSOFT_CLIENT_SECRET ?? '', + ...params, + }) + + const tenant = process.env.MICROSOFT_TENANT_ID ?? 'common' + const response = await fetch( + `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, + { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }, + ) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Outlook token exchange failed: ${error}`) + } + + return response.json() as Promise +} \ No newline at end of file diff --git a/backend/services/service-categories.js b/backend/services/service-categories.js deleted file mode 100644 index 4360fa9..0000000 --- a/backend/services/service-categories.js +++ /dev/null @@ -1,353 +0,0 @@ -'use strict'; - -/** - * Rule-based lookup table for subscription category auto-tagging. - * Keys are normalised service names (lowercase, trimmed). - * Covers 200+ popular subscription services. - */ -const SERVICE_CATEGORIES = { - // ── Entertainment ───────────────────────────────────────────────────────── - netflix: 'entertainment', - 'netflix premium': 'entertainment', - hulu: 'entertainment', - 'hulu live': 'entertainment', - 'disney+': 'entertainment', - 'disney plus': 'entertainment', - 'hbo max': 'entertainment', - max: 'entertainment', - 'apple tv+': 'entertainment', - 'apple tv plus': 'entertainment', - 'amazon prime video': 'entertainment', - 'prime video': 'entertainment', - 'paramount+': 'entertainment', - 'paramount plus': 'entertainment', - peacock: 'entertainment', - 'peacock premium': 'entertainment', - discovery: 'entertainment', - 'discovery+': 'entertainment', - fubo: 'entertainment', - fubotv: 'entertainment', - sling: 'entertainment', - 'sling tv': 'entertainment', - philo: 'entertainment', - crunchyroll: 'entertainment', - funimation: 'entertainment', - 'mubi': 'entertainment', - shudder: 'entertainment', - 'britbox': 'entertainment', - acorn: 'entertainment', - 'acorn tv': 'entertainment', - curiositystream: 'entertainment', - tubi: 'entertainment', - pluto: 'entertainment', - 'plex pass': 'entertainment', - 'twitch turbo': 'entertainment', - 'youtube premium': 'entertainment', - dazn: 'entertainment', - espn: 'entertainment', - 'espn+': 'entertainment', - 'nfl+': 'entertainment', - 'nba league pass': 'entertainment', - 'mlb.tv': 'entertainment', - - // ── Music ───────────────────────────────────────────────────────────────── - spotify: 'entertainment', - 'spotify premium': 'entertainment', - 'apple music': 'entertainment', - 'amazon music': 'entertainment', - 'amazon music unlimited': 'entertainment', - tidal: 'entertainment', - deezer: 'entertainment', - 'pandora premium': 'entertainment', - qobuz: 'entertainment', - soundcloud: 'entertainment', - 'soundcloud go': 'entertainment', - 'youtube music': 'entertainment', - 'bandcamp': 'entertainment', - - // ── Productivity ────────────────────────────────────────────────────────── - notion: 'productivity', - 'notion ai': 'productivity', - obsidian: 'productivity', - roam: 'productivity', - 'roam research': 'productivity', - evernote: 'productivity', - todoist: 'productivity', - things: 'productivity', - 'things 3': 'productivity', - omnifocus: 'productivity', - 'any.do': 'productivity', - ticktick: 'productivity', - 'microsoft 365': 'productivity', - 'office 365': 'productivity', - 'google workspace': 'productivity', - 'google one': 'productivity', - dropbox: 'productivity', - 'dropbox plus': 'productivity', - 'dropbox business': 'productivity', - box: 'productivity', - 'icloud+': 'productivity', - onedrive: 'productivity', - airtable: 'productivity', - coda: 'productivity', - basecamp: 'productivity', - asana: 'productivity', - monday: 'productivity', - 'monday.com': 'productivity', - clickup: 'productivity', - trello: 'productivity', - jira: 'productivity', - confluence: 'productivity', - 'atlassian': 'productivity', - linear: 'productivity', - height: 'productivity', - shortcut: 'productivity', - smartsheet: 'productivity', - wrike: 'productivity', - teamwork: 'productivity', - 'teamwork projects': 'productivity', - notion: 'productivity', - miro: 'productivity', - figma: 'productivity', - 'figma professional': 'productivity', - lucidchart: 'productivity', - whimsical: 'productivity', - loom: 'productivity', - otter: 'productivity', - 'otter.ai': 'productivity', - zoom: 'productivity', - 'zoom pro': 'productivity', - webex: 'productivity', - 'microsoft teams': 'productivity', - slack: 'productivity', - 'slack pro': 'productivity', - discord: 'productivity', - twist: 'productivity', - chanty: 'productivity', - 'google meet': 'productivity', - calendly: 'productivity', - cal: 'productivity', - 'cal.com': 'productivity', - doodle: 'productivity', - 'hubspot crm': 'productivity', - pipedrive: 'productivity', - salesforce: 'productivity', - freshdesk: 'productivity', - intercom: 'productivity', - zendesk: 'productivity', - 'help scout': 'productivity', - - // ── AI Tools ────────────────────────────────────────────────────────────── - chatgpt: 'ai_tools', - 'chatgpt plus': 'ai_tools', - 'chatgpt pro': 'ai_tools', - 'openai': 'ai_tools', - claude: 'ai_tools', - 'claude pro': 'ai_tools', - 'anthropic': 'ai_tools', - midjourney: 'ai_tools', - 'dall-e': 'ai_tools', - 'stable diffusion': 'ai_tools', - 'adobe firefly': 'ai_tools', - 'github copilot': 'ai_tools', - 'cursor': 'ai_tools', - codeium: 'ai_tools', - tabnine: 'ai_tools', - 'amazon codewhisperer': 'ai_tools', - replit: 'ai_tools', - 'replit ai': 'ai_tools', - 'gemini advanced': 'ai_tools', - 'google gemini': 'ai_tools', - perplexity: 'ai_tools', - 'perplexity pro': 'ai_tools', - jasper: 'ai_tools', - 'jasper ai': 'ai_tools', - copy: 'ai_tools', - 'copy.ai': 'ai_tools', - writesonic: 'ai_tools', - grammarly: 'ai_tools', - 'grammarly premium': 'ai_tools', - hemingway: 'ai_tools', - 'hemingway editor': 'ai_tools', - elevenlabs: 'ai_tools', - runway: 'ai_tools', - 'runway ml': 'ai_tools', - pika: 'ai_tools', - synthesia: 'ai_tools', - heygen: 'ai_tools', - descript: 'ai_tools', - 'notion ai': 'ai_tools', - 'superhuman': 'ai_tools', - - // ── Infrastructure / Developer ──────────────────────────────────────────── - aws: 'infrastructure', - 'amazon web services': 'infrastructure', - 'google cloud': 'infrastructure', - 'google cloud platform': 'infrastructure', - gcp: 'infrastructure', - azure: 'infrastructure', - 'microsoft azure': 'infrastructure', - digitalocean: 'infrastructure', - linode: 'infrastructure', - vultr: 'infrastructure', - vercel: 'infrastructure', - 'vercel pro': 'infrastructure', - netlify: 'infrastructure', - 'netlify pro': 'infrastructure', - heroku: 'infrastructure', - render: 'infrastructure', - railway: 'infrastructure', - fly: 'infrastructure', - 'fly.io': 'infrastructure', - cloudflare: 'infrastructure', - 'cloudflare pro': 'infrastructure', - fastly: 'infrastructure', - datadog: 'infrastructure', - sentry: 'infrastructure', - 'new relic': 'infrastructure', - pagerduty: 'infrastructure', - github: 'infrastructure', - 'github pro': 'infrastructure', - 'github team': 'infrastructure', - gitlab: 'infrastructure', - bitbucket: 'infrastructure', - 'bitbucket premium': 'infrastructure', - jfrog: 'infrastructure', - 'docker hub': 'infrastructure', - supabase: 'infrastructure', - firebase: 'infrastructure', - 'mongodb atlas': 'infrastructure', - planetscale: 'infrastructure', - neon: 'infrastructure', - fauna: 'infrastructure', - upstash: 'infrastructure', - redis: 'infrastructure', - 'redis cloud': 'infrastructure', - twilio: 'infrastructure', - sendgrid: 'infrastructure', - mailgun: 'infrastructure', - postmark: 'infrastructure', - resend: 'infrastructure', - algolia: 'infrastructure', - 'elastic cloud': 'infrastructure', - segment: 'infrastructure', - mixpanel: 'infrastructure', - amplitude: 'infrastructure', - logrocket: 'infrastructure', - fullstory: 'infrastructure', - hotjar: 'infrastructure', - - // ── Design / Creative ──────────────────────────────────────────────────── - 'adobe creative cloud': 'productivity', - 'adobe cc': 'productivity', - 'adobe photoshop': 'productivity', - 'adobe illustrator': 'productivity', - 'adobe premiere': 'productivity', - 'adobe xd': 'productivity', - canva: 'productivity', - 'canva pro': 'productivity', - sketch: 'productivity', - affinity: 'productivity', - 'affinity designer': 'productivity', - 'affinity photo': 'productivity', - procreate: 'productivity', - 'final cut pro': 'productivity', - davinci: 'productivity', - 'davinci resolve': 'productivity', - 'logic pro': 'entertainment', - ableton: 'entertainment', - 'ableton live': 'entertainment', - - // ── Education ───────────────────────────────────────────────────────────── - coursera: 'education', - udemy: 'education', - skillshare: 'education', - linkedin: 'education', - 'linkedin learning': 'education', - pluralsight: 'education', - 'pluralsight one': 'education', - treehouse: 'education', - 'team treehouse': 'education', - codecademy: 'education', - 'codecademy pro': 'education', - datacamp: 'education', - 'frontend masters': 'education', - egghead: 'education', - 'egghead.io': 'education', - masterclass: 'education', - brilliance: 'education', - brilliant: 'education', - duolingo: 'education', - 'duolingo plus': 'education', - babbel: 'education', - rosetta: 'education', - 'rosetta stone': 'education', - pimsleur: 'education', - blinkist: 'education', - audible: 'education', - scribd: 'education', - 'kindle unlimited': 'education', - 'o\'reilly': 'education', - 'oreilly': 'education', - - // ── Health & Fitness ───────────────────────────────────────────────────── - peloton: 'health', - 'peloton app': 'health', - 'apple fitness+': 'health', - 'apple fitness': 'health', - strava: 'health', - 'strava summit': 'health', - headspace: 'health', - calm: 'health', - 'calm premium': 'health', - waking: 'health', - 'waking up': 'health', - noom: 'health', - myfitnesspal: 'health', - 'myfitnesspal premium': 'health', - whoop: 'health', - oura: 'health', - 'oura ring': 'health', - eight: 'health', - 'eight sleep': 'health', - 'beachbody on demand': 'health', - 'future fitness': 'health', - teladoc: 'health', - 'hims & hers': 'health', - betterhelp: 'health', - talkspace: 'health', - hinge: 'health', - 'hinge premium': 'health', - 'bumble premium': 'health', - 'match premium': 'health', - - // ── Finance ─────────────────────────────────────────────────────────────── - quickbooks: 'finance', - 'quickbooks online': 'finance', - freshbooks: 'finance', - xero: 'finance', - 'wave accounting': 'finance', - mint: 'finance', - 'ynab': 'finance', - 'you need a budget': 'finance', - copilot: 'finance', - 'copilot money': 'finance', - 'personal capital': 'finance', - empower: 'finance', - acorns: 'finance', - robinhood: 'finance', - 'robinhood gold': 'finance', - 'public.com': 'finance', - betterment: 'finance', - wealthfront: 'finance', - expensify: 'finance', - divvy: 'finance', - brex: 'finance', - ramp: 'finance', - stripe: 'finance', - 'stripe atlas': 'finance', - 'turbotax': 'finance', - 'h&r block': 'finance', -}; - -module.exports = SERVICE_CATEGORIES; \ No newline at end of file diff --git a/backend/services/service-categories.ts b/backend/services/service-categories.ts new file mode 100644 index 0000000..440f34a --- /dev/null +++ b/backend/services/service-categories.ts @@ -0,0 +1,349 @@ +/** + * Rule-based lookup table for subscription category auto-tagging. + * Keys are normalised service names (lowercase, trimmed). + * Covers 200+ popular subscription services. + */ +const SERVICE_CATEGORIES: Record = { + // ── Entertainment ───────────────────────────────────────────────────────── + netflix: 'entertainment', + 'netflix premium': 'entertainment', + hulu: 'entertainment', + 'hulu live': 'entertainment', + 'disney+': 'entertainment', + 'disney plus': 'entertainment', + 'hbo max': 'entertainment', + max: 'entertainment', + 'apple tv+': 'entertainment', + 'apple tv plus': 'entertainment', + 'amazon prime video': 'entertainment', + 'prime video': 'entertainment', + 'paramount+': 'entertainment', + 'paramount plus': 'entertainment', + peacock: 'entertainment', + 'peacock premium': 'entertainment', + discovery: 'entertainment', + 'discovery+': 'entertainment', + fubo: 'entertainment', + fubotv: 'entertainment', + sling: 'entertainment', + 'sling tv': 'entertainment', + philo: 'entertainment', + crunchyroll: 'entertainment', + funimation: 'entertainment', + mubi: 'entertainment', + shudder: 'entertainment', + britbox: 'entertainment', + acorn: 'entertainment', + 'acorn tv': 'entertainment', + curiositystream: 'entertainment', + tubi: 'entertainment', + pluto: 'entertainment', + 'plex pass': 'entertainment', + 'twitch turbo': 'entertainment', + 'youtube premium': 'entertainment', + dazn: 'entertainment', + espn: 'entertainment', + 'espn+': 'entertainment', + 'nfl+': 'entertainment', + 'nba league pass': 'entertainment', + 'mlb.tv': 'entertainment', + + // ── Music ───────────────────────────────────────────────────────────────── + spotify: 'entertainment', + 'spotify premium': 'entertainment', + 'apple music': 'entertainment', + 'amazon music': 'entertainment', + 'amazon music unlimited': 'entertainment', + tidal: 'entertainment', + deezer: 'entertainment', + 'pandora premium': 'entertainment', + qobuz: 'entertainment', + soundcloud: 'entertainment', + 'soundcloud go': 'entertainment', + 'youtube music': 'entertainment', + bandcamp: 'entertainment', + + // ── Productivity ────────────────────────────────────────────────────────── + notion: 'productivity', + 'notion ai': 'ai_tools', + obsidian: 'productivity', + roam: 'productivity', + 'roam research': 'productivity', + evernote: 'productivity', + todoist: 'productivity', + things: 'productivity', + 'things 3': 'productivity', + omnifocus: 'productivity', + 'any.do': 'productivity', + ticktick: 'productivity', + 'microsoft 365': 'productivity', + 'office 365': 'productivity', + 'google workspace': 'productivity', + 'google one': 'productivity', + dropbox: 'productivity', + 'dropbox plus': 'productivity', + 'dropbox business': 'productivity', + box: 'productivity', + 'icloud+': 'productivity', + onedrive: 'productivity', + airtable: 'productivity', + coda: 'productivity', + basecamp: 'productivity', + asana: 'productivity', + monday: 'productivity', + 'monday.com': 'productivity', + clickup: 'productivity', + trello: 'productivity', + jira: 'productivity', + confluence: 'productivity', + atlassian: 'productivity', + linear: 'productivity', + height: 'productivity', + shortcut: 'productivity', + smartsheet: 'productivity', + wrike: 'productivity', + teamwork: 'productivity', + 'teamwork projects': 'productivity', + miro: 'productivity', + figma: 'productivity', + 'figma professional': 'productivity', + lucidchart: 'productivity', + whimsical: 'productivity', + loom: 'productivity', + otter: 'productivity', + 'otter.ai': 'productivity', + zoom: 'productivity', + 'zoom pro': 'productivity', + webex: 'productivity', + 'microsoft teams': 'productivity', + slack: 'productivity', + 'slack pro': 'productivity', + discord: 'productivity', + twist: 'productivity', + chanty: 'productivity', + 'google meet': 'productivity', + calendly: 'productivity', + cal: 'productivity', + 'cal.com': 'productivity', + doodle: 'productivity', + 'hubspot crm': 'productivity', + pipedrive: 'productivity', + salesforce: 'productivity', + freshdesk: 'productivity', + intercom: 'productivity', + zendesk: 'productivity', + 'help scout': 'productivity', + + // ── AI Tools ────────────────────────────────────────────────────────────── + chatgpt: 'ai_tools', + 'chatgpt plus': 'ai_tools', + 'chatgpt pro': 'ai_tools', + openai: 'ai_tools', + claude: 'ai_tools', + 'claude pro': 'ai_tools', + anthropic: 'ai_tools', + midjourney: 'ai_tools', + 'dall-e': 'ai_tools', + 'stable diffusion': 'ai_tools', + 'adobe firefly': 'ai_tools', + 'github copilot': 'ai_tools', + cursor: 'ai_tools', + codeium: 'ai_tools', + tabnine: 'ai_tools', + 'amazon codewhisperer': 'ai_tools', + replit: 'ai_tools', + 'replit ai': 'ai_tools', + 'gemini advanced': 'ai_tools', + 'google gemini': 'ai_tools', + perplexity: 'ai_tools', + 'perplexity pro': 'ai_tools', + jasper: 'ai_tools', + 'jasper ai': 'ai_tools', + copy: 'ai_tools', + 'copy.ai': 'ai_tools', + writesonic: 'ai_tools', + grammarly: 'ai_tools', + 'grammarly premium': 'ai_tools', + hemingway: 'ai_tools', + 'hemingway editor': 'ai_tools', + elevenlabs: 'ai_tools', + runway: 'ai_tools', + 'runway ml': 'ai_tools', + pika: 'ai_tools', + synthesia: 'ai_tools', + heygen: 'ai_tools', + descript: 'ai_tools', + superhuman: 'ai_tools', + + // ── Infrastructure / Developer ──────────────────────────────────────────── + aws: 'infrastructure', + 'amazon web services': 'infrastructure', + 'google cloud': 'infrastructure', + 'google cloud platform': 'infrastructure', + gcp: 'infrastructure', + azure: 'infrastructure', + 'microsoft azure': 'infrastructure', + digitalocean: 'infrastructure', + linode: 'infrastructure', + vultr: 'infrastructure', + vercel: 'infrastructure', + 'vercel pro': 'infrastructure', + netlify: 'infrastructure', + 'netlify pro': 'infrastructure', + heroku: 'infrastructure', + render: 'infrastructure', + railway: 'infrastructure', + fly: 'infrastructure', + 'fly.io': 'infrastructure', + cloudflare: 'infrastructure', + 'cloudflare pro': 'infrastructure', + fastly: 'infrastructure', + datadog: 'infrastructure', + sentry: 'infrastructure', + 'new relic': 'infrastructure', + pagerduty: 'infrastructure', + github: 'infrastructure', + 'github pro': 'infrastructure', + 'github team': 'infrastructure', + gitlab: 'infrastructure', + bitbucket: 'infrastructure', + 'bitbucket premium': 'infrastructure', + jfrog: 'infrastructure', + 'docker hub': 'infrastructure', + supabase: 'infrastructure', + firebase: 'infrastructure', + 'mongodb atlas': 'infrastructure', + planetscale: 'infrastructure', + neon: 'infrastructure', + fauna: 'infrastructure', + upstash: 'infrastructure', + redis: 'infrastructure', + 'redis cloud': 'infrastructure', + twilio: 'infrastructure', + sendgrid: 'infrastructure', + mailgun: 'infrastructure', + postmark: 'infrastructure', + resend: 'infrastructure', + algolia: 'infrastructure', + 'elastic cloud': 'infrastructure', + segment: 'infrastructure', + mixpanel: 'infrastructure', + amplitude: 'infrastructure', + logrocket: 'infrastructure', + fullstory: 'infrastructure', + hotjar: 'infrastructure', + + // ── Design / Creative ──────────────────────────────────────────────────── + 'adobe creative cloud': 'productivity', + 'adobe cc': 'productivity', + 'adobe photoshop': 'productivity', + 'adobe illustrator': 'productivity', + 'adobe premiere': 'productivity', + 'adobe xd': 'productivity', + canva: 'productivity', + 'canva pro': 'productivity', + sketch: 'productivity', + affinity: 'productivity', + 'affinity designer': 'productivity', + 'affinity photo': 'productivity', + procreate: 'productivity', + 'final cut pro': 'productivity', + davinci: 'productivity', + 'davinci resolve': 'productivity', + 'logic pro': 'entertainment', + ableton: 'entertainment', + 'ableton live': 'entertainment', + + // ── Education ───────────────────────────────────────────────────────────── + coursera: 'education', + udemy: 'education', + skillshare: 'education', + linkedin: 'education', + 'linkedin learning': 'education', + pluralsight: 'education', + 'pluralsight one': 'education', + treehouse: 'education', + 'team treehouse': 'education', + codecademy: 'education', + 'codecademy pro': 'education', + datacamp: 'education', + 'frontend masters': 'education', + egghead: 'education', + 'egghead.io': 'education', + masterclass: 'education', + brilliance: 'education', + brilliant: 'education', + duolingo: 'education', + 'duolingo plus': 'education', + babbel: 'education', + rosetta: 'education', + 'rosetta stone': 'education', + pimsleur: 'education', + blinkist: 'education', + audible: 'education', + scribd: 'education', + 'kindle unlimited': 'education', + "o'reilly": 'education', + oreilly: 'education', + + // ── Health & Fitness ───────────────────────────────────────────────────── + peloton: 'health', + 'peloton app': 'health', + 'apple fitness+': 'health', + 'apple fitness': 'health', + strava: 'health', + 'strava summit': 'health', + headspace: 'health', + calm: 'health', + 'calm premium': 'health', + waking: 'health', + 'waking up': 'health', + noom: 'health', + myfitnesspal: 'health', + 'myfitnesspal premium': 'health', + whoop: 'health', + oura: 'health', + 'oura ring': 'health', + eight: 'health', + 'eight sleep': 'health', + 'beachbody on demand': 'health', + 'future fitness': 'health', + teladoc: 'health', + 'hims & hers': 'health', + betterhelp: 'health', + talkspace: 'health', + hinge: 'health', + 'hinge premium': 'health', + 'bumble premium': 'health', + 'match premium': 'health', + + // ── Finance ─────────────────────────────────────────────────────────────── + quickbooks: 'finance', + 'quickbooks online': 'finance', + freshbooks: 'finance', + xero: 'finance', + 'wave accounting': 'finance', + mint: 'finance', + ynab: 'finance', + 'you need a budget': 'finance', + copilot: 'finance', + 'copilot money': 'finance', + 'personal capital': 'finance', + empower: 'finance', + acorns: 'finance', + robinhood: 'finance', + 'robinhood gold': 'finance', + 'public.com': 'finance', + betterment: 'finance', + wealthfront: 'finance', + expensify: 'finance', + divvy: 'finance', + brex: 'finance', + ramp: 'finance', + stripe: 'finance', + 'stripe atlas': 'finance', + turbotax: 'finance', + 'h&r block': 'finance', +} + +export default SERVICE_CATEGORIES \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 8749b20..654586a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -26,6 +26,9 @@ import { monitoringService } from './services/monitoring-service'; import { healthService } from './services/health-service'; import { eventListener } from './services/event-listener'; import { expiryService } from './services/expiry-service'; +import gmailRouter from '../routes/integrations/gmail' +import outlookRouter from '../routes/integrations/outlook' +import { authenticate } from './middleware/auth' import { scheduleAutoResume } from './jobs/auto-resume'; const app = express(); @@ -75,6 +78,8 @@ app.use('/api/simulation', simulationRoutes); app.use('/api/merchants', merchantRoutes); app.use('/api/team', teamRoutes); app.use('/api/audit', auditRoutes); +app.use('/api/integrations/gmail', authenticate, gmailRouter) +app.use('/api/integrations/outlook', authenticate, outlookRouter) app.use('/api/webhooks', webhookRoutes); app.use('/api/tags', tagsRoutes); app.use('/api', tagsRoutes); // handles /api/subscriptions/:id/notes and /api/subscriptions/:id/tags diff --git a/backend/utils/oauth-state.js b/backend/utils/oauth-state.ts similarity index 51% rename from backend/utils/oauth-state.js rename to backend/utils/oauth-state.ts index 091f74c..cc853e0 100644 --- a/backend/utils/oauth-state.js +++ b/backend/utils/oauth-state.ts @@ -1,30 +1,21 @@ -const crypto = require('crypto') +import crypto from 'crypto' const STATE_TTL_MS = 10 * 60 * 1000 -const stateStore = new Map() +const stateStore = new Map() -function createState() { +export function createState(): string { const state = crypto.randomBytes(16).toString('hex') stateStore.set(state, Date.now() + STATE_TTL_MS) return state } -function consumeState(state) { - if (!state) { - return false - } +export function consumeState(state: string | undefined): boolean { + if (!state) return false const expiresAt = stateStore.get(state) stateStore.delete(state) - if (!expiresAt) { - return false - } + if (!expiresAt) return false return Date.now() <= expiresAt -} - -module.exports = { - createState, - consumeState, -} +} \ No newline at end of file diff --git a/backend/utils/proof-hashing.js b/backend/utils/proof-hashing.js deleted file mode 100644 index 3c4517a..0000000 --- a/backend/utils/proof-hashing.js +++ /dev/null @@ -1,43 +0,0 @@ -const crypto = require('crypto') - -function sha256(input) { - return crypto.createHash('sha256').update(input).digest('hex') -} - -function hashContent(content) { - if (!content) { - return null - } - return sha256(String(content)) -} - -function generateProofHash({ - provider, - messageId, - receivedAt, - subject, - from, - amount, - currency, - interval, - contentHash, -}) { - const parts = [ - provider || '', - messageId || '', - receivedAt || '', - subject || '', - from || '', - amount != null ? String(amount) : '', - currency || '', - interval || '', - contentHash || '', - ] - - return sha256(parts.join('|')) -} - -module.exports = { - hashContent, - generateProofHash, -} diff --git a/backend/utils/proof-hashing.ts b/backend/utils/proof-hashing.ts new file mode 100644 index 0000000..5f468e1 --- /dev/null +++ b/backend/utils/proof-hashing.ts @@ -0,0 +1,54 @@ +import crypto from 'crypto' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface ProofHashInput { + provider?: string | null + messageId?: string | null + receivedAt?: string | null + subject?: string | null + from?: string | null + amount?: number | null + currency?: string | null + interval?: string | null + contentHash?: string | null +} + +// ── Exported functions ──────────────────────────────────────────────────────── + +export function hashContent(content?: string | null): string | null { + if (!content) return null + return sha256(String(content)) +} + +export function generateProofHash({ + provider, + messageId, + receivedAt, + subject, + from, + amount, + currency, + interval, + contentHash, +}: ProofHashInput): string { + const parts = [ + provider ?? '', + messageId ?? '', + receivedAt ?? '', + subject ?? '', + from ?? '', + amount != null ? String(amount) : '', + currency ?? '', + interval ?? '', + contentHash ?? '', + ] + + return sha256(parts.join('|')) +} + +// ── Internal helper ─────────────────────────────────────────────────────────── + +function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex') +} \ No newline at end of file