diff --git a/bun.lock b/bun.lock index c02e0f27..8f3c7de9 100644 --- a/bun.lock +++ b/bun.lock @@ -20,10 +20,13 @@ "fast-xml-parser": "^5.11.2", "imapflow": "^2.2.6", "mailparser": "^3.9.36", + "mammoth": "^1.13.0", "nodemailer": "^10.0.15", "resend": "^6.7.0", "tldts": "^7.4.19", "ts-results-es": "^7.1.0", + "unpdf": "^1.9.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.5.4", }, "devDependencies": { @@ -688,6 +691,8 @@ "@webgpu/types": ["@webgpu/types@0.1.74", "", {}, "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="], + "@xyflow/react": ["@xyflow/react@12.12.0", "", { "dependencies": { "@xyflow/system": "0.0.83", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ=="], "@xyflow/system": ["@xyflow/system@0.0.83", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw=="], @@ -712,6 +717,8 @@ "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], @@ -822,6 +829,8 @@ "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], @@ -886,6 +895,8 @@ "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.2", "", {}, "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg=="], + "docs": ["docs@workspace:packages/docs"], "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], @@ -904,6 +915,8 @@ "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], + "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1146,6 +1159,8 @@ "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], "its-fine": ["its-fine@2.1.1", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-C5ZoVkeqSt0Aje0tzESO+iT/EQpNFNL39GkP7mCACtoZUFD6GcyerFCr6kp0ZtiII4W9HoYkY48Soy5xE/uHKw=="], @@ -1166,6 +1181,8 @@ "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jszip": ["jszip@3.10.2", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "leac": ["leac@0.7.0", "", {}, "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw=="], @@ -1212,6 +1229,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@1.52.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ=="], @@ -1224,6 +1243,8 @@ "mailparser": ["mailparser@3.9.36", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.20", "encoding-japanese": "2.4.0", "he": "1.2.0", "html-to-text": "10.0.1", "iconv-lite": "0.7.3", "libmime": "5.4.7", "linkify-it": "5.0.2", "nodemailer": "10.0.14", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-Fng3OmPN7khG0oVHDwwBIOYxjGu7kFxEuN6NnVV1H2tQYCIWjqvjK6cKvhj2r3XQVToVabP0b4HF+N7YgNdXFQ=="], + "mammoth": ["mammoth@1.13.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-2cawcYFcP7ISfXbBcL9lmVgTNWo88v/52ND1gnywHtVT/pX0//puPnf0iMPV9ZgmuvNOE+zkIhDgZ9M2c2dDDg=="], + "map": ["map@workspace:packages/map"], "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], @@ -1406,6 +1427,8 @@ "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], "oxlint": ["oxlint@1.87.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.87.0", "@oxlint/binding-android-arm64": "1.87.0", "@oxlint/binding-darwin-arm64": "1.87.0", "@oxlint/binding-darwin-x64": "1.87.0", "@oxlint/binding-freebsd-x64": "1.87.0", "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", "@oxlint/binding-linux-arm-musleabihf": "1.87.0", "@oxlint/binding-linux-arm64-gnu": "1.87.0", "@oxlint/binding-linux-arm64-musl": "1.87.0", "@oxlint/binding-linux-ppc64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-gnu": "1.87.0", "@oxlint/binding-linux-riscv64-musl": "1.87.0", "@oxlint/binding-linux-s390x-gnu": "1.87.0", "@oxlint/binding-linux-x64-gnu": "1.87.0", "@oxlint/binding-linux-x64-musl": "1.87.0", "@oxlint/binding-openharmony-arm64": "1.87.0", "@oxlint/binding-win32-arm64-msvc": "1.87.0", "@oxlint/binding-win32-ia32-msvc": "1.87.0", "@oxlint/binding-win32-x64-msvc": "1.87.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2003", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg=="], @@ -1416,6 +1439,8 @@ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], @@ -1476,6 +1501,8 @@ "prisma": ["prisma@6.19.3", "", { "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "process-warning": ["process-warning@5.1.0", "", {}, "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw=="], "promise-worker-transferable": ["promise-worker-transferable@1.0.4", "", { "dependencies": { "is-promise": "^2.1.0", "lie": "^3.0.2" } }, "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw=="], @@ -1520,6 +1547,8 @@ "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -1596,6 +1625,8 @@ "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shadcn": ["shadcn@4.21.3", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@shadcn/registry": "0.1.2", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "cn": "^0.2.4", "commander": "^14.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.23", "postcss-selector-parser": "^7.1.3", "prompts": "^2.4.2", "recast": "^0.23.11", "socks": "^2.8.8", "stringify-object": "^5.0.0", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.29.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-r88yKaFvIwz2DInrE/DZn0XZVSuKWahouQVJBjYxVAcZ3GRkfh+3VDhIeCc18eYiIOuMgZoGaPYjhzhaON2mzw=="], @@ -1634,6 +1665,8 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], @@ -1648,6 +1681,8 @@ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], @@ -1728,6 +1763,8 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + "undici": ["undici@7.30.0", "", {}, "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -1750,6 +1787,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpdf": ["unpdf@1.9.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-nO1npM2kNyd4hj1fKzUVU6bvnWjDSxI97eycM/DgzI83dYyW+pNRI9NeMQVEfef8fI3Q06i9lkMeE1HxrQqKoA=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], @@ -1794,8 +1833,12 @@ "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], @@ -1922,6 +1965,8 @@ "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1940,6 +1985,8 @@ "stats-gl/@types/three": ["@types/three@0.186.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.3", "meshoptimizer": "~1.1.1" } }, "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ=="], + "string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "three-stdlib/fflate": ["fflate@0.6.11", "", {}, "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg=="], diff --git a/packages/api/package.json b/packages/api/package.json index a6057ec9..33dda1c9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -21,10 +21,13 @@ "fast-xml-parser": "^5.11.2", "imapflow": "^2.2.6", "mailparser": "^3.9.36", + "mammoth": "^1.13.0", "nodemailer": "^10.0.15", "resend": "^6.7.0", "tldts": "^7.4.19", "ts-results-es": "^7.1.0", + "unpdf": "^1.9.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", "zod": "^4.5.4" }, "devDependencies": { diff --git a/packages/api/src/agent-side/tools.test.ts b/packages/api/src/agent-side/tools.test.ts index b2718e96..6cf65da5 100644 --- a/packages/api/src/agent-side/tools.test.ts +++ b/packages/api/src/agent-side/tools.test.ts @@ -130,7 +130,8 @@ const draft = {id: 'drafts-7-3', values: {folder: 'Draft', from: 'alex@example.c const archive = mock(); const send = mock(); const sendNew = mock(); -const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew}}; +const read = mock(); +const connector = {list: mock(), get: mock(), create: mock(), update: mock(), remove: mock(), commands: {archive, send}, newCommands: {sendNew, read}}; function useConnection(connection: typeof vaultConnection | typeof emailConnection | typeof notionConnection | typeof googleConnection) { loadConnection.mockResolvedValue(Ok({connection, integration: integration(connection.integrationId), connector})); @@ -228,7 +229,7 @@ describe('listAgentTools', () => { const {listAgentTools} = await import('./tools'); const result = await listAgentTools(signedIn, 'mail-1'); - expect(result.unwrap().map((tool) => tool.name)).toEqual(['emails_list', 'emails_get', 'emails_send', 'emails_send_new']); + expect(result.unwrap().map((tool) => tool.name)).toEqual(['emails_list', 'emails_get', 'emails_send', 'emails_send_new', 'attachments_read']); }); }); @@ -409,6 +410,18 @@ describe('runAgentTool', () => { expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({action: 'sendNew', recordTitle: 'Friday', outcome: 'allowed'}); }); + test('an attachment read by its number is logged by the name it came back with', async () => { + useConnection(emailConnection); + getAgentById.mockResolvedValue(Ok({...agent, grants: [{connectionId: 'mail-1', actionId: 'read', allowed: true}]})); + read.mockResolvedValue(Ok({id: 'inbox-7-12/1', values: {email: 'inbox-7-12', attachment: '1', name: 'Budget.xlsx', content: 'Tab: Q3'}, updatedAt: '2026-10-01T00:00:00.000Z'})); + + const {runAgentTool} = await import('./tools'); + const result = await runAgentTool(caller, 'mail-1', 'attachments_read', {email: 'inbox-7-12', attachment: '1'}); + + expect(result.unwrap()).toMatchObject({record: {values: {name: 'Budget.xlsx'}}}); + expect(logAgentRequest.mock.calls[0]?.[0]).toMatchObject({collectionId: 'attachments', action: 'read', recordTitle: 'Budget.xlsx', outcome: 'allowed'}); + }); + test('a run the provider fails is logged as failed with what went wrong, not as allowed, and passed on', async () => { useConnection(emailConnection); getAgentById.mockResolvedValue(Ok(triage)); diff --git a/packages/api/src/agent-side/tools.ts b/packages/api/src/agent-side/tools.ts index 21c3f1cc..091cc3ec 100644 --- a/packages/api/src/agent-side/tools.ts +++ b/packages/api/src/agent-side/tools.ts @@ -435,7 +435,8 @@ function newCommand(run: ToolRun, params: Record): Promise ({updateConnectionCredential: mock(), mock.module('../../utils/env', () => ({env: {NODE_ENV: 'production', APP_URL: 'https://app.example.com', GOOGLE_CLIENT_ID: 'client-1', GOOGLE_CLIENT_SECRET: 'secret-1'}})); -const {attachmentNames, folderOf, folderQuery, htmlToText, messageBody} = await import('./gmail-api'); +const {attachmentsOf, folderOf, folderQuery, messageBody} = await import('./gmail-api'); const base64url = (text: string, encoding: BufferEncoding = 'utf8'): string => Buffer.from(text, encoding).toString('base64url'); @@ -77,7 +77,7 @@ describe('messageBody', () => { expect(messageBody(textPart('text/html', html))).toBe('Your order has shipped & is on its way.\nTrack it here (https://example.com/track/1).\n\n- One\n- Two'); }); - test('nested parts are walked, a text part in the charset it names, and the attachments listed after', () => { + test('nested parts are walked, a text part in the charset it names, and the attachments found apart from it', () => { const latin = {mimeType: 'text/plain', filename: '', headers: [{name: 'Content-Type', value: 'text/plain; charset=ISO-8859-1'}], body: {size: 12, data: base64url('Grüße, Sam', 'latin1')}}; const payload: MessagePart = { mimeType: 'multipart/mixed', @@ -85,14 +85,37 @@ describe('messageBody', () => { parts: [{mimeType: 'multipart/alternative', filename: '', parts: [latin, textPart('text/html', '

Grüße

')]}, attachment('invoice.pdf'), attachment('receipt.pdf')], }; - expect(messageBody(payload)).toBe('Grüße, Sam\n\nAttachments: invoice.pdf, receipt.pdf'); - expect(attachmentNames(payload)).toEqual(['invoice.pdf', 'receipt.pdf']); + expect(messageBody(payload)).toBe('Grüße, Sam'); + expect(attachmentsOf(payload)).toEqual([ + {name: 'invoice.pdf', type: 'application/pdf', size: 1200, data: null, attachmentId: 'att-1'}, + {name: 'receipt.pdf', type: 'application/pdf', size: 1200, data: null, attachmentId: 'att-1'}, + ]); + }); + + test('a part naming another charset whose content Gmail hands over as UTF-8 is read as UTF-8', () => { + const part = { + mimeType: 'text/plain', + filename: '', + headers: [{name: 'Content-Type', value: 'text/plain; charset="iso-8859-1"'}], + body: {size: 30, data: base64url('mit der Zusammenfassung bin ich selbstverständlich einverstanden')}, + }; + + expect(messageBody(part)).toBe('mit der Zusammenfassung bin ich selbstverständlich einverstanden'); + }); + + test('a part in a charset TextDecoder doesn’t know, or naming none, that isn’t UTF-8 is read as Windows-1252', () => { + const unknown = {mimeType: 'text/plain', filename: '', headers: [{name: 'Content-Type', value: 'text/plain; charset=x-unknown'}], body: {size: 6, data: base64url('Grüße', 'latin1')}}; + const unnamed = {mimeType: 'text/plain', filename: '', headers: [{name: 'Content-Type', value: 'text/plain'}], body: {size: 6, data: base64url('Grüße', 'latin1')}}; + + expect(messageBody(unknown)).toBe('Grüße'); + expect(messageBody(unnamed)).toBe('Grüße'); }); test('a text attachment is not the body', () => { const payload: MessagePart = {mimeType: 'multipart/mixed', filename: '', parts: [{...textPart('text/plain', 'notes'), filename: 'notes.txt'}, textPart('text/html', '

The body

')]}; - expect(messageBody(payload)).toBe('The body\n\nAttachments: notes.txt'); + expect(messageBody(payload)).toBe('The body'); + expect(attachmentsOf(payload).map(({name, data}) => [name, data])).toEqual([['notes.txt', base64url('notes')]]); }); test('a message without parts is empty', () => { @@ -104,30 +127,3 @@ describe('messageBody', () => { expect(messageBody(textPart('text/html', '

Hi\r\nAlex,

\r\n

See you\nFriday.

'))).toBe('Hi Alex,\nSee you Friday.'); }); }); - -test('htmlToText finds the end of a hidden element after a letter that lowercases to two', () => { - expect(htmlToText('

İstanbul

after

')).toBe('İstanbul\nafter'); - expect(htmlToText(`

${'İ'.repeat(12)} before

after

more

`)).toBe(`${'İ'.repeat(12)} before\nafter\nmore`); -}); - -test('htmlToText decodes entities only once the tags are gone', () => { - expect(htmlToText('

<b> is bold — ☺

')).toBe(' is bold — ☺'); -}); - -test('htmlToText reads a number Unicode has no character for as the replacement character', () => { - expect(htmlToText('

a�b�c�d

')).toBe('a\uFFFDb\uFFFDc\uFFFDd'); -}); - -test('htmlToText keeps the text of unclosed tags, and of a link that never ends', () => { - expect(htmlToText('

Hi there', open + 4); - at = end === -1 ? html.length : end + 3; - continue; - } - const close = html.indexOf('>', open); - if (close === -1) { - parts.push(sourceText(html.slice(open))); - break; - } - const tag = html.slice(open, close + 1); - at = close + 1; - const [, slash = '', rawName = ''] = /^<(\/?)([a-z][a-z0-9]*)/i.exec(tag) ?? []; - const name = rawName.toLowerCase(); - const closing = slash === '/'; - if (!closing && HIDDEN.includes(name)) { - const end = indexOfTag(html, `', end); - at = endClose === -1 ? html.length : endClose + 1; - continue; - } - if (name === 'a' && !closing) { - const href = /\shref\s*=\s*"([^"]*)"/i.exec(tag)?.[1]; - link = href === undefined ? null : {href, start: parts.length}; - continue; - } - if (name === 'a' && closing && link) { - const label = parts.splice(link.start).join('').trim(); - parts.push(label === '' || label === link.href ? link.href : `${label} (${link.href})`); - link = null; - continue; - } - parts.push(tagText(name, closing)); - } - return decodeEntities(parts.join('')) - .replace(/[ \t\u00a0]+/g, ' ') - .replace(/ ?\n ?/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - -// Text between tags: a line break in the HTML's source is a space, as a browser shows it. -function sourceText(text: string): string { - return text.replace(/\r\n?|\n/g, ' '); -} +export type GmailAttachment = {name: string; type: string; size: number; data: string | null; attachmentId: string | null}; -// Where a tag next starts from `from`, whatever its case. The email isn't lowercased whole to look, -// since a letter can lowercase to more than one, as İ does, which would move everything after it. -function indexOfTag(html: string, tag: string, from: number): number { - const pattern = new RegExp(tag, 'ig'); - pattern.lastIndex = from; - return pattern.exec(html)?.index ?? -1; +/** A message's attachments, inline images included, in the order of its parts. */ +export function attachmentsOf(part: MessagePart | undefined): GmailAttachment[] { + if (!part) { + return []; + } + const own = isAttachment(part) + ? [{name: part.filename ?? '', type: part.mimeType ?? '', size: part.body?.size ?? 0, data: part.body?.data ?? null, attachmentId: part.body?.attachmentId ?? null}] + : []; + return [...own, ...(part.parts ?? []).flatMap(attachmentsOf)]; } /** A message's HTML part, as its sender wrote it, or null for one without. */ @@ -297,19 +186,17 @@ export function messageHtml(payload: MessagePart | undefined): string | null { return html ? decodePart(html) : null; } -/** A message's text: its plain text part, else its HTML part as text, then the names of its attachments. */ +/** A message's text: its plain text part, else its HTML part as text. */ export function messageBody(payload: MessagePart | undefined): string { if (!payload) { return ''; } const plain = findText(payload, 'text/plain'); - const html = plain ? null : findText(payload, 'text/html'); - const text = plain ? decodePart(plain).replace(/\r\n?/g, '\n').trim() : html ? htmlToText(decodePart(html)) : ''; - const attachments = attachmentNames(payload); - if (attachments.length === 0) { - return text; + if (plain) { + return decodePart(plain).replace(/\r\n?/g, '\n').trim(); } - return `${text}\n\nAttachments: ${attachments.join(', ')}`.trim(); + const html = findText(payload, 'text/html'); + return html ? htmlToText(decodePart(html)) : ''; } // ---- Calls ----------------------------------------------------------------------------------- @@ -398,6 +285,14 @@ export function getMessage(accessToken: string, messageId: string): Promise> { + const answer = await googleRequest(accessToken, gmailUrl(`/messages/${messageId}/attachments/${attachmentId}`), attachmentSchema); + return answer.map(({data}) => Buffer.from(data, 'base64url')); +} + /** Adds and removes labels on a message, as marking, starring and archiving do. */ export function modifyMessage(accessToken: string, messageId: string, change: {add?: string[]; remove?: string[]}): Promise> { return googleWrite(accessToken, 'POST', gmailUrl(`/messages/${messageId}/modify`), {addLabelIds: change.add ?? [], removeLabelIds: change.remove ?? []}, messageSchema); diff --git a/packages/api/src/records/attachment-text.test.ts b/packages/api/src/records/attachment-text.test.ts new file mode 100644 index 00000000..3181f3f5 --- /dev/null +++ b/packages/api/src/records/attachment-text.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, test} from 'bun:test'; +import * as XLSX from 'xlsx'; + +import {attachmentRecord, attachmentsText, pickAttachment, type AttachmentFile} from './attachment-text'; + +function file(name: string, type: string, bytes: Uint8Array): AttachmentFile { + return {name, type, size: bytes.length, bytes}; +} + +const text = (value: string): Uint8Array => new TextEncoder().encode(value); + +function spreadsheet(tabs: Record): Uint8Array { + const book = XLSX.utils.book_new(); + for (const [name, sheet] of Object.entries(tabs)) { + XLSX.utils.book_append_sheet(book, sheet, name); + } + return new Uint8Array(XLSX.write(book, {type: 'array', bookType: 'xlsx'})); +} + +// A zip of files kept as they are, uncompressed, as a .docx is read. +function zip(files: Record): Uint8Array { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + for (const [name, content] of Object.entries(files)) { + const nameBytes = Buffer.from(name); + const data = Buffer.from(content); + const crc = Bun.hash.crc32(data); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const directory = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(centrals.length / 2, 8); + end.writeUInt16LE(centrals.length / 2, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return new Uint8Array(Buffer.concat([...locals, directory, end])); +} + +function wordDocument(paragraphs: string[]): Uint8Array { + const body = paragraphs.map((paragraph) => `${paragraph}`).join(''); + return zip({ + '[Content_Types].xml': + '', + '_rels/.rels': + '', + 'word/document.xml': `${body}`, + }); +} + +// A one-page PDF with a line of text on it, or none. +function pdf(line: string | null): Uint8Array { + const stream = line === null ? '' : `BT /F1 12 Tf 72 720 Td (${line}) Tj ET`; + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>', + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + let source = '%PDF-1.4\n'; + const offsets = objects.map((object, index) => { + const at = source.length; + source += `${index + 1} 0 obj\n${object}\nendobj\n`; + return at; + }); + const xref = source.length; + source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((at) => `${String(at).padStart(10, '0')} 00000 n \n`).join('')}`; + source += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return text(source); +} + +describe('attachmentsText', () => { + test('lists each attachment on a line, numbered, with its type and size', () => { + expect( + attachmentsText([ + {name: 'Q3 budget.xlsx', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 48_213}, + {name: 'photo.jpg', type: 'image/jpeg', size: 3_400_000}, + {name: '', type: '', size: 200}, + ]), + ).toBe('1. Q3 budget.xlsx (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 47 KB)\n2. photo.jpg (image/jpeg, 3.2 MB)\n3. Untitled (unknown type, 200 B)'); + }); +}); + +describe('pickAttachment', () => { + const attachments = [ + {name: 'notes.txt', type: 'text/plain', size: 5}, + {name: 'Budget.xlsx', type: 'application/octet-stream', size: 9}, + ]; + + test('finds an attachment by its number, its name, or its name in any case', () => { + expect(pickAttachment(attachments, {attachment: '2'}).unwrap()).toEqual({attachment: {name: 'Budget.xlsx', type: 'application/octet-stream', size: 9}, number: 2}); + expect(pickAttachment(attachments, {attachment: 'notes.txt'}).unwrap().number).toBe(1); + expect(pickAttachment(attachments, {attachment: ' budget.XLSX '}).unwrap().number).toBe(2); + }); + + test('turns down one the email hasn’t, listing those it has', () => { + const error = pickAttachment(attachments, {attachment: '3'}).unwrapErr(); + + expect(error).toMatchObject({kind: 'validation_error'}); + expect(error.kind === 'validation_error' ? error.message : '').toBe( + 'This email has no attachment “3”. Its attachments are:\n1. notes.txt (text/plain, 5 B)\n2. Budget.xlsx (application/octet-stream, 9 B)', + ); + expect(pickAttachment([], {attachment: '1'}).unwrapErr()).toMatchObject({message: 'This email has no attachments'}); + expect(pickAttachment(attachments, {}).unwrapErr()).toMatchObject({kind: 'validation_error'}); + }); +}); + +describe('attachmentRecord', () => { + test('reads a spreadsheet as CSV, tab by tab, its values as Excel shows them, leaving out empty tabs', async () => { + const overview: XLSX.WorkSheet = { + '!ref': 'A1:E2', + A1: {t: 's', v: 'System'}, + B1: {t: 's', v: 'Users'}, + C1: {t: 's', v: 'Since'}, + D1: {t: 's', v: 'Until'}, + E1: {t: 's', v: 'Cost'}, + A2: {t: 's', v: 'ERP, finance'}, + B2: {t: 'n', v: 140}, + // 1 March 2019 and 30 June 2025 at 14:30, in a format SheetJS can show and in a German one it can't. + C2: {t: 'n', v: 43525, z: 'dd/mm/yyyy'}, + D2: {t: 'n', v: 45838.604166666664, z: 'dd.mm.yyyy hh:mm'}, + E2: {t: 'n', v: 1234.5, z: '#,##0.00 "€"'}, + }; + const bytes = spreadsheet({Overview: overview, Empty: XLSX.utils.aoa_to_sheet([]), Notes: XLSX.utils.aoa_to_sheet([['Archived “as is”']])}); + + const record = (await attachmentRecord('msg-1', 2, file('Overview.xlsx', 'application/octet-stream', bytes))).unwrap(); + + expect(record.id).toBe('msg-1/2'); + expect(record.values).toMatchObject({email: 'msg-1', attachment: '2', name: 'Overview.xlsx', type: 'application/octet-stream'}); + expect(record.values.content).toBe('Tab: Overview\nSystem,Users,Since,Until,Cost\n"ERP, finance",140,01/03/2019,2025-06-30 14:30,"1,234.50 €"\n\nTab: Notes\nArchived “as is”'); + expect(record.values.length).toBe(`${record.values.content?.length} characters`); + }); + + test('reads a Word document’s text', async () => { + const record = ( + await attachmentRecord('msg-1', 1, file('Letter.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', wordDocument(['Dear Dana,', 'the review is on Friday.']))) + ).unwrap(); + + expect(record.values.content).toBe('Dear Dana,\n\nthe review is on Friday.'); + }); + + test('reads a PDF’s text, and turns down one with none, as a scan', async () => { + const record = (await attachmentRecord('msg-1', 1, file('Invoice.pdf', 'application/pdf', pdf('Invoice 1042: 1,200 EUR')))).unwrap(); + + expect(record.values.content).toBe('Invoice 1042: 1,200 EUR'); + expect((await attachmentRecord('msg-1', 1, file('Scan.pdf', 'application/pdf', pdf(null)))).unwrapErr()).toMatchObject({ + kind: 'validation_error', + message: '“Scan.pdf” has no text in it, as a scan hasn’t: only its pictures', + }); + }); + + test('reads a forwarded email with its headers and text', async () => { + const source = + 'From: Dana \r\nTo: Sam \r\nSubject: Pricing review\r\nDate: Tue, 27 Oct 2026 09:00:00 +0000\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nSee you on Friday.\r\n'; + + const record = (await attachmentRecord('msg-1', 1, file('Pricing review.eml', 'message/rfc822', text(source)))).unwrap(); + + expect(record.values.content).toBe('From: "Dana" \nTo: "Sam" \nDate: 2026-10-27T09:00:00.000Z\nSubject: Pricing review\n\nSee you on Friday.'); + }); + + test('reads text as it is, in its charset where it isn’t UTF-8, and HTML as text', async () => { + expect((await attachmentRecord('m', 1, file('notes.csv', 'text/csv', text('a,b\r\n1,2')))).unwrap().values.content).toBe('a,b\n1,2'); + expect((await attachmentRecord('m', 1, file('notes.txt', 'text/plain; charset=iso-8859-1', new Uint8Array(Buffer.from('Grüße', 'latin1'))))).unwrap().values.content).toBe('Grüße'); + expect((await attachmentRecord('m', 1, file('page.html', 'text/html', text('

Hello & welcome

')))).unwrap().values.content).toBe('Hello & welcome'); + }); + + test('answers the first 100,000 characters of a long one, saying so', async () => { + const record = (await attachmentRecord('m', 1, file('log.txt', 'text/plain', text('x'.repeat(150_000))))).unwrap(); + + expect(record.values.content?.length).toBe(100_000); + expect(record.values.length).toBe('The first 100000 of 150000 characters'); + }); + + test('turns down images and other files, and a spreadsheet that isn’t one', async () => { + expect((await attachmentRecord('m', 1, file('photo.jpg', 'image/jpeg', new Uint8Array([0xff, 0xd8])))).unwrapErr()).toMatchObject({ + kind: 'validation_error', + message: '“photo.jpg” (image/jpeg) can’t be read as text: only spreadsheets, PDFs, Word documents, emails and text can', + }); + expect((await attachmentRecord('m', 1, file('smime.p7s', 'application/pkcs7-signature', new Uint8Array([1])))).isErr()).toBe(true); + expect((await attachmentRecord('m', 1, file('broken.docx', 'application/octet-stream', text('not a zip')))).unwrapErr()).toMatchObject({kind: 'validation_error'}); + }); +}); diff --git a/packages/api/src/records/attachment-text.ts b/packages/api/src/records/attachment-text.ts new file mode 100644 index 00000000..a7317828 --- /dev/null +++ b/packages/api/src/records/attachment-text.ts @@ -0,0 +1,242 @@ +import {simpleParser} from 'mailparser'; +import mammoth from 'mammoth'; +import {extractText, getDocumentProxy} from 'unpdf'; +import * as XLSX from 'xlsx'; +import {Err, Ok, Result} from 'ts-results-es'; + +import {ApiErr, type ApiError, Do} from '@proxy/utils'; + +import {decodeText} from '../utils/decode-text'; +import {htmlToText} from '../utils/html-to-text'; +import type {DataRecord, RecordValues} from './connector'; + +/** + * An email's attachments as the `attachments` collection, the same for Gmail and any mailbox: + * never listed or opened, only read, one at a time, by the email's id and the attachment's number + * among the email's attachments, as an email lists them, or its name. An attachment is read as + * text: a spreadsheet's tabs as CSV, one after the other, a PDF's or a Word document's text, an + * email's headers and text, and text as it is. Anything else, such as an image, is turned down. + */ + +/** An attachment as an email lists it. */ +export type AttachmentInfo = {name: string; type: string; size: number}; + +/** An attachment with its content. */ +export type AttachmentFile = AttachmentInfo & {bytes: Uint8Array}; + +// The characters of an attachment's text answered at most, as a web page's are. +const MAX_CHARACTERS = 100_000; + +// The largest attachment read: Gmail's own limit on what an email carries. +const MAX_BYTES = 25 * 1024 * 1024; + +function sizeText(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${Math.round(bytes / 1024)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** An email's attachments, one a line, numbered as they are read by: "1. Q3.xlsx (application/…, 48 KB)". */ +export function attachmentsText(attachments: AttachmentInfo[]): string { + return attachments.map((attachment, index) => `${index + 1}. ${attachment.name || 'Untitled'} (${attachment.type || 'unknown type'}, ${sizeText(attachment.size)})`).join('\n'); +} + +/** The attachment `values.attachment` names, by its number or its name, and its number. */ +export function pickAttachment(attachments: T[], values: RecordValues): Result<{attachment: T; number: number}, ApiError> { + const which = (values.attachment ?? '').trim(); + if (attachments.length === 0) { + return Err(ApiErr.validationError('This email has no attachments')); + } + const listed = `Its attachments are:\n${attachmentsText(attachments)}`; + if (which === '') { + return Err(ApiErr.validationError(`Say which attachment as attachment: its number or its name. ${listed}`)); + } + const byNumber = /^\d+$/.test(which) ? Number(which) - 1 : -1; + const byName = attachments.findIndex((attachment) => attachment.name === which); + const index = [byNumber, byName, attachments.findIndex((attachment) => attachment.name.toLowerCase() === which.toLowerCase())].find((candidate) => candidate >= 0 && candidate < attachments.length); + const attachment = index === undefined ? undefined : attachments[index]; + if (index === undefined || !attachment) { + return Err(ApiErr.validationError(`This email has no attachment “${which}”. ${listed}`)); + } + return Ok({attachment, number: index + 1}); +} + +/** The email `values.email` names, by its id as a list gives it. */ +export function requireEmailId(values: RecordValues): Result { + const id = (values.email ?? '').trim(); + if (id === '') { + return Err(ApiErr.validationError('Say which email as email: its id, as a list gives it')); + } + return Ok(id); +} + +type Kind = 'spreadsheet' | 'pdf' | 'word' | 'email' | 'html' | 'text'; + +const EXTENSIONS: Array<[Kind, string[]]> = [ + ['spreadsheet', ['xlsx', 'xlsm', 'xlsb', 'xls', 'ods', 'numbers']], + ['pdf', ['pdf']], + ['word', ['docx']], + ['email', ['eml']], + ['html', ['html', 'htm']], + ['text', ['txt', 'csv', 'tsv', 'md', 'json', 'xml', 'ics', 'vcf', 'log', 'yaml', 'yml']], +]; + +const TYPES: Array<[Kind, RegExp]> = [ + ['spreadsheet', /^application\/(vnd\.openxmlformats-officedocument\.spreadsheetml\.|vnd\.ms-excel|vnd\.oasis\.opendocument\.spreadsheet)/], + ['pdf', /^application\/pdf$/], + ['word', /^application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document$/], + ['email', /^message\/rfc822$/], + ['html', /^text\/html$/], + ['text', /^(text\/|application\/(json|xml|csv)$)/], +]; + +// What a file is by its name first, since mail clients often send any file as +// application/octet-stream, then by its type. +function kindOf(file: AttachmentInfo): Kind | null { + const extension = /\.([a-z0-9]+)$/i.exec(file.name)?.[1]?.toLowerCase() ?? ''; + const byName = EXTENSIONS.find(([, extensions]) => extensions.includes(extension))?.[0]; + if (byName) { + return byName; + } + return TYPES.find(([, pattern]) => pattern.test(file.type.toLowerCase()))?.[0] ?? null; +} + +function unreadable(file: AttachmentInfo, cause: unknown): ApiError { + const why = cause instanceof Error ? `: ${cause.message}` : ''; + return ApiErr.validationError(`“${file.name}” couldn’t be read${why}`); +} + +// A date as 2026-03-01, with its time where it has one: 2026-03-01 14:30. +function dateText(date: Date): string { + const [day = '', time = ''] = date.toISOString().split('T'); + return time.startsWith('00:00:00') ? day : `${day} ${time.slice(0, 5)}`; +} + +// A tab as CSV, its values as the spreadsheet shows them. A date in a format SheetJS can't show, +// as it can't German ones like dd.mm.yyyy, is written as 2026-03-01 rather than as its number. +function sheetCsv(sheet: XLSX.WorkSheet): string { + for (const [address, cell] of Object.entries(sheet)) { + if (!address.startsWith('!') && cell.t === 'd' && cell.w === undefined && cell.v instanceof Date) { + cell.w = dateText(cell.v); + } + } + return XLSX.utils.sheet_to_csv(sheet, {blankrows: false, strip: true}).trim(); +} + +// Every tab with anything in it, as CSV, under its name. +function spreadsheetText(file: AttachmentFile): Result { + return Result.wrap(() => { + const book = XLSX.read(file.bytes, {type: 'array', cellDates: true}); + return book.SheetNames.flatMap((name) => { + const sheet = book.Sheets[name]; + const csv = sheet ? sheetCsv(sheet) : ''; + return csv === '' ? [] : [`Tab: ${name}\n${csv}`]; + }).join('\n\n'); + }).mapErr((cause) => unreadable(file, cause)); +} + +async function pdfText(file: AttachmentFile): Promise> { + return Do(async ($) => { + const pdf = $((await Result.wrapAsync(() => getDocumentProxy(new Uint8Array(file.bytes)))).mapErr((cause) => unreadable(file, cause))); + const {text} = $((await Result.wrapAsync(() => extractText(pdf, {mergePages: false}))).mapErr((cause) => unreadable(file, cause))); + const pages = text.map((page) => page.trim()); + if (pages.every((page) => page === '')) { + return $(Err(ApiErr.validationError(`“${file.name}” has no text in it, as a scan hasn’t: only its pictures`))); + } + return pages.join('\n\n'); + }); +} + +async function wordText(file: AttachmentFile): Promise> { + const extracted = await Result.wrapAsync(() => mammoth.extractRawText({buffer: Buffer.from(file.bytes)})); + return extracted.map(({value}) => value.replace(/\n{3,}/g, '\n\n').trim()).mapErr((cause) => unreadable(file, cause)); +} + +// A forwarded email: who it was from and to, when, its subject and its text. +async function emailText(file: AttachmentFile): Promise> { + const parsed = await Result.wrapAsync(() => simpleParser(Buffer.from(file.bytes))); + return parsed + .map((mail) => { + const headers = [ + ['From', mail.from?.text], + [ + 'To', + [mail.to ?? []] + .flat() + .map((to) => to.text) + .join(', '), + ], + ['Date', mail.date?.toISOString()], + ['Subject', mail.subject], + ].flatMap(([name, value]) => (value ? [`${name}: ${value}`] : [])); + const body = (mail.text ?? (mail.html ? htmlToText(mail.html) : '')).trim(); + const attachments = mail.attachments.length > 0 ? `\n\nAttachments: ${mail.attachments.map((attachment) => attachment.filename ?? 'Untitled').join(', ')}` : ''; + return `${headers.join('\n')}\n\n${body}${attachments}`; + }) + .mapErr((cause) => unreadable(file, cause)); +} + +function charsetOf(type: string): string | null { + return /charset="?([^";\s]+)"?/i.exec(type)?.[1] ?? null; +} + +function textOf(file: AttachmentFile, kind: Kind): Promise> { + if (kind === 'spreadsheet') { + return Promise.resolve(spreadsheetText(file)); + } + if (kind === 'pdf') { + return pdfText(file); + } + if (kind === 'word') { + return wordText(file); + } + if (kind === 'email') { + return emailText(file); + } + const text = decodeText(file.bytes, charsetOf(file.type)); + if (kind === 'html') { + return Promise.resolve(Ok(htmlToText(text))); + } + return Promise.resolve(Ok(text.replace(/\r\n?/g, '\n'))); +} + +function lengthText(length: number): string { + if (length > MAX_CHARACTERS) { + return `The first ${MAX_CHARACTERS} of ${length} characters`; + } + return `${length} characters`; +} + +/** What an attachment is read as, by its name and type, before its content is fetched; one too large or that isn't text is turned down. */ +export function readableKind(attachment: AttachmentInfo): Result { + const kind = kindOf(attachment); + if (kind === null) { + return Err(ApiErr.validationError(`“${attachment.name}” (${attachment.type || 'unknown type'}) can’t be read as text: only spreadsheets, PDFs, Word documents, emails and text can`)); + } + if (attachment.size > MAX_BYTES) { + return Err(ApiErr.validationError(`“${attachment.name}” is too large to read, at ${sizeText(attachment.size)}: at most ${sizeText(MAX_BYTES)}`)); + } + return Ok(kind); +} + +/** An attachment read as text, as the `attachments` collection answers it. */ +export function attachmentRecord(email: string, number: number, file: AttachmentFile): Promise> { + return Do(async ($) => { + const kind = $(readableKind({...file, size: Math.max(file.size, file.bytes.length)})); + const text = $(await textOf(file, kind)); + const values = { + email, + attachment: String(number), + name: file.name, + type: file.type, + size: sizeText(file.size || file.bytes.length), + length: lengthText(text.length), + content: text.slice(0, MAX_CHARACTERS), + }; + return {id: `${email}/${number}`, values, updatedAt: new Date().toISOString()}; + }); +} diff --git a/packages/api/src/records/email-connector.test.ts b/packages/api/src/records/email-connector.test.ts index c948d535..98d05fb7 100644 --- a/packages/api/src/records/email-connector.test.ts +++ b/packages/api/src/records/email-connector.test.ts @@ -307,6 +307,59 @@ describe('emails', () => { }); }); +describe('attachments', () => { + const withAttachments = Buffer.from( + [ + 'From: sam@example.com', + 'To: alex@example.com', + 'Subject: Q3 budget', + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="b"', + '', + '--b', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Budget attached.', + '--b', + 'Content-Type: text/csv; name="Budget.csv"', + 'Content-Disposition: attachment; filename="Budget.csv"', + 'Content-Transfer-Encoding: base64', + '', + Buffer.from('Item,Cost\r\nVenue,1200').toString('base64'), + '--b--', + '', + ].join('\r\n'), + ); + + function attachmentsTarget() { + const integration = findIntegration('email'); + const collection = integration && findCollection(integration, 'attachments'); + if (!collection) { + throw new Error('No attachments collection'); + } + return {...target, collection}; + } + + test('an email opens with its attachments listed, and one is read by its number', async () => { + imap.message = {uid: 12, flags: new Set(), source: withAttachments}; + + const {emailConnector} = await import('./email-connector'); + const record = (await emailConnector.get(target, INBOX_EMAIL)).unwrap(); + const read = (await emailConnector.newCommands?.read?.(attachmentsTarget(), {email: INBOX_EMAIL, attachment: '1'}))?.unwrap(); + + expect(record.values.body).toBe('Budget attached.'); + expect(record.values.attachments).toBe('1. Budget.csv (text/csv, 21 B)'); + expect(read?.values).toMatchObject({email: INBOX_EMAIL, name: 'Budget.csv', content: 'Item,Cost\nVenue,1200'}); + }); + + test('an email without attachments says so', async () => { + const {emailConnector} = await import('./email-connector'); + const result = await emailConnector.newCommands?.read?.(attachmentsTarget(), {email: INBOX_EMAIL, attachment: '1'}); + + expect(result?.unwrapErr()).toMatchObject({kind: 'validation_error', message: 'This email has no attachments'}); + }); +}); + describe('findInboxEmails', () => { test("searches the inbox in IMAP's own terms, newest first, at most as many as asked for", async () => { imap.boxes.INBOX = fill(6, '2026-10-01T09:00:00Z'); diff --git a/packages/api/src/records/email-connector.ts b/packages/api/src/records/email-connector.ts index 9822b156..525e43fa 100644 --- a/packages/api/src/records/email-connector.ts +++ b/packages/api/src/records/email-connector.ts @@ -1,7 +1,7 @@ import {randomUUID} from 'node:crypto'; import {ImapFlow, type FetchMessageObject, type MailboxObject, type MessageAddressObject, type SearchObject} from 'imapflow'; -import {simpleParser} from 'mailparser'; +import {simpleParser, type ParsedMail} from 'mailparser'; import MailComposer from 'nodemailer/lib/mail-composer'; import type Mail from 'nodemailer/lib/mailer'; import {Err, Ok, Result} from 'ts-results-es'; @@ -13,6 +13,7 @@ import type {EmailCredential} from '../connections/email/credential'; import {imapClientOptions, imapError, smtpError, smtpTransport} from '../connections/email/mail-check'; import {log, serializeError} from '../observability/log'; import {decryptSecret} from '../utils/secret-crypto'; +import {attachmentRecord, attachmentsText, pickAttachment, requireEmailId, type AttachmentFile} from './attachment-text'; import type {CommandRunner, Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; import {gmailQuery, type EmailSearch} from './email-search'; import {replyHeaders, type MailReply, type ReplyHeaders} from './email-reply'; @@ -26,7 +27,8 @@ import {replyHeaders, type MailReply, type ReplyHeaders} from './email-reply'; * * IMAP messages can't be changed, so updating a draft saves a new one and deletes the old: the * draft gets a new id. Received emails are only flagged or moved: archived, or moved to Trash. - * Email goes out over SMTP, from a draft or straight from values typed in. + * Email goes out over SMTP, from a draft or straight from values typed in. An email's attachments + * are read from the message whole, as the `attachments` collection's one command. */ const PAGE_SIZE = 50; @@ -455,7 +457,8 @@ function listPage(target: RecordTarget, query: ListQuery): Promise> { +// A message whole, as it was fetched and as mailparser reads it. +async function parsedMessage(session: Session, id: string): Promise> { return Do(async ($) => { const uid = $(uidOf(session, id)); const message = await session.client.fetchOne(String(uid), {uid: true, envelope: true, internalDate: true, flags: true, source: true}, {uid: true}); @@ -465,8 +468,35 @@ async function getMessage(session: Session, id: string): Promise simpleParser(source))).mapErr((cause) => ApiErr.providerUnreachable(cause))); + return {message, parsed}; + }); +} + +function attachmentFiles(parsed: ParsedMail): AttachmentFile[] { + return parsed.attachments.map((attachment) => ({name: attachment.filename ?? '', type: attachment.contentType, size: attachment.size, bytes: attachment.content})); +} + +async function getMessage(session: Session, id: string): Promise> { + return Do(async ($) => { + const {message, parsed} = $(await parsedMessage(session, id)); const body = (parsed.text ?? '').trim(); - return {id, values: {...summaryValues(session.folder, message), body}, updatedAt: updatedAt(message)}; + return {id, values: {...summaryValues(session.folder, message), body, attachments: attachmentsText(attachmentFiles(parsed))}, updatedAt: updatedAt(message)}; + }); +} + +/** One of an email's attachments, read as text: the `attachments` collection's one command. */ +function readAttachment(target: RecordTarget, values: RecordValues): Promise> { + return Do(async ($) => { + const email = $(requireEmailId(values)); + return $( + await withRecord(target, email, (session) => + Do(async ($$) => { + const {parsed} = $$(await parsedMessage(session, email)); + const {attachment, number} = $$(pickAttachment(attachmentFiles(parsed), values)); + return $$(await attachmentRecord(email, number, attachment)); + }), + ), + ); }); } @@ -681,5 +711,7 @@ export const emailConnector: Connector = { }, newCommands: { sendNew: (target, values) => withClient(target, async (client, credential) => (await deliver(client, credential, values)).map(() => null)), + // The `attachments` collection's, the only command it has. + read: readAttachment, }, }; diff --git a/packages/api/src/records/google-connector.ts b/packages/api/src/records/google-connector.ts index 63049a17..0d38e1a4 100644 --- a/packages/api/src/records/google-connector.ts +++ b/packages/api/src/records/google-connector.ts @@ -5,7 +5,7 @@ import {ApiErr, type ApiError} from '@proxy/utils'; import type {CommandRunner, Connector, NewCommandRunner, RecordTarget} from './connector'; import {availabilityConnector} from './google-availability'; import {contactsConnector} from './google-contacts'; -import {gmailConnector} from './google-emails'; +import {gmailAttachmentsConnector, gmailConnector} from './google-emails'; import {eventsConnector} from './google-events'; import {createFilesRecord, getFilesRecord, listFilesTree, trashFilesRecord, updateFilesRecord} from './google-files'; import {otherContactsConnector} from './google-other-contacts'; @@ -14,9 +14,10 @@ import {tasksConnector} from './google-tasks'; /** * A Google account, fetched live from Google's APIs with the connection's tokens, one collection - * per product: `emails`, Gmail (`google-emails.ts`), `events`, Calendar (`google-events.ts`), - * `availability`, when people are busy (`google-availability.ts`), `files`, Drive with Docs, - * Sheets and Slides (`google-files.ts`), `ranges`, a spreadsheet's values in A1 notation + * per product: `emails`, Gmail, and `attachments`, its emails' attachments read as text + * (`google-emails.ts`), `events`, Calendar (`google-events.ts`), `availability`, when people are + * busy (`google-availability.ts`), `files`, Drive with Docs, Sheets and Slides + * (`google-files.ts`), `ranges`, a spreadsheet's values in A1 notation * (`google-ranges.ts`), `contacts`, Google Contacts (`google-contacts.ts`), `otherContacts`, the * people Gmail keeps under Other contacts (`google-other-contacts.ts`), and `tasks`, Google Tasks * (`google-tasks.ts`). Commands go to the collection they were run on, since two can share an id, @@ -39,6 +40,7 @@ const filesConnector: Connector = { const COLLECTIONS: Partial> = { emails: gmailConnector, + attachments: gmailAttachmentsConnector, events: eventsConnector, availability: availabilityConnector, files: filesConnector, diff --git a/packages/api/src/records/google-emails.test.ts b/packages/api/src/records/google-emails.test.ts index b2ab1046..388116ce 100644 --- a/packages/api/src/records/google-emails.test.ts +++ b/packages/api/src/records/google-emails.test.ts @@ -97,6 +97,8 @@ const gmail = { calls: [] as Call[], messages: mailbox(), drafts: {r1: 'm3'} as Record, + // An attachment's content by its id, of m1's. + attachments: {} as Record, answer: (_call: Call): Response | null => null, }; @@ -140,6 +142,11 @@ function answer(call: Call): Response { if (draft) { return draftAnswer(draft); } + const [, attachmentOf, attachmentId] = /^\/messages\/([^/]+)\/attachments\/([^/]+)$/.exec(path) ?? []; + if (attachmentOf && attachmentId) { + const content = attachmentOf === 'm1' ? gmail.attachments[attachmentId] : undefined; + return content === undefined ? notFound() : Response.json({size: content.length, data: base64url(content)}); + } const [, id, verb] = /^\/messages\/([^/]+)(?:\/(modify|trash))?$/.exec(path) ?? []; const found = id ? gmail.messages[id] : undefined; if (!found) { @@ -179,6 +186,7 @@ beforeEach(() => { gmail.calls = []; gmail.messages = mailbox(); gmail.drafts = {r1: 'm3'}; + gmail.attachments = {}; gmail.answer = () => null; }); @@ -326,6 +334,60 @@ describe('get', () => { }); }); +describe('attachments', () => { + function attachments(): Collection { + const integration = findIntegration('google'); + const found = integration && findCollection(integration, 'attachments'); + if (!found) { + throw new Error('No attachments collection'); + } + return found; + } + + const attachmentsTarget = {...target, collection: attachments()}; + + beforeEach(() => { + gmail.messages.m1?.payload.parts?.push( + {mimeType: 'text/csv', filename: 'Budget.csv', body: {size: 22, attachmentId: 'att-budget'}}, + {mimeType: 'image/png', filename: 'logo.png', body: {size: 2048, attachmentId: 'att-logo'}}, + ); + gmail.messages.m3?.payload.parts?.push({mimeType: 'text/plain', filename: 'notes.txt', body: {size: 5, data: base64url('Notes')}}); + gmail.attachments = {'att-budget': 'Item,Cost\r\nVenue,1200'}; + }); + + test('an email opens with its attachments listed, numbered, apart from its text', async () => { + const record = (await (await connector()).get(target, 'm1')).unwrap(); + + expect(record.values.body).toBe('Agenda attached.'); + expect(record.values.attachments).toBe('1. Budget.csv (text/csv, 22 B)\n2. logo.png (image/png, 2 KB)'); + }); + + test('an attachment is read by its number, fetched by the id the email gives it', async () => { + const read = (await connector()).newCommands?.read; + const record = (await read?.(attachmentsTarget, {email: 'm1', attachment: '1'}))?.unwrap(); + + expect(record?.values).toMatchObject({email: 'm1', attachment: '1', name: 'Budget.csv', type: 'text/csv', content: 'Item,Cost\nVenue,1200'}); + expect(calls('GET', '/messages/m1/attachments/att-budget')).toHaveLength(1); + }); + + test('a draft’s attachment is read by its name, from the draft itself where its content is in it', async () => { + const read = (await connector()).newCommands?.read; + const record = (await read?.(attachmentsTarget, {email: 'draft-r1', attachment: 'notes.txt'}))?.unwrap(); + + expect(record?.values.content).toBe('Notes'); + expect(gmail.calls.some((call) => call.url.pathname.includes('/attachments/'))).toBe(false); + }); + + test('an image is turned down, and an email Gmail doesn’t have is not found', async () => { + const read = (await connector()).newCommands?.read; + + expect((await read?.(attachmentsTarget, {email: 'm1', attachment: '2'}))?.unwrapErr()).toMatchObject({kind: 'validation_error', message: expect.stringContaining('can’t be read as text')}); + expect(calls('GET', '/messages/m1/attachments/att-logo')).toEqual([]); + expect((await read?.(attachmentsTarget, {email: 'missing', attachment: '1'}))?.unwrapErr()).toMatchObject({kind: 'not_found', id: 'missing'}); + expect((await read?.(attachmentsTarget, {attachment: '1'}))?.unwrapErr()).toMatchObject({kind: 'validation_error'}); + }); +}); + describe('drafts', () => { test('a new draft is written from the connection’s address and read back by its draft id', async () => { const record = (await (await connector()).create(target, {to: 'robin@example.com', subject: 'New', body: 'Hello'}, null)).unwrap(); diff --git a/packages/api/src/records/google-emails.ts b/packages/api/src/records/google-emails.ts index 6826708a..7e32c22a 100644 --- a/packages/api/src/records/google-emails.ts +++ b/packages/api/src/records/google-emails.ts @@ -9,11 +9,12 @@ import {ApiErr, type ApiError, Do, mapWithLimit, requirePresent} from '@proxy/ut import {callGoogle} from '../connections/google/google-client'; import { - attachmentNames, + attachmentsOf, createDraft, deleteDraft, findDraftIds, folderOf, + getAttachment, folderQuery, getDraft, getMessage, @@ -35,7 +36,8 @@ import { type GmailMessage, type MessagePart, } from '../connections/google/gmail-api'; -import type {CommandRunner, Connector, DataRecord, ListQuery, RecordPage, RecordTarget, RecordValues} from './connector'; +import {attachmentRecord, attachmentsText, pickAttachment, readableKind, requireEmailId} from './attachment-text'; +import {onlyRun, type CommandRunner, type Connector, type DataRecord, type ListQuery, type NewCommandRunner, type RecordPage, type RecordTarget, type RecordValues} from './connector'; import {gmailQuery, type EmailSearch} from './email-search'; import {replyHeaders, type MailReply} from './email-reply'; @@ -158,7 +160,7 @@ function toRecord(account: string, recordId: string, message: GmailMessage, valu } function fullRecord(account: string, recordId: string, message: GmailMessage): DataRecord { - return toRecord(account, recordId, message, {...summaryValues(message), body: messageBody(message.payload)}); + return toRecord(account, recordId, message, {...summaryValues(message), body: messageBody(message.payload), attachments: attachmentsText(attachmentsOf(message.payload))}); } // A draft in the drafts, not one moved to Trash, which Gmail doesn't list among them. @@ -318,7 +320,7 @@ function updateDraftRecord(target: RecordTarget, recordId: string, values: Recor const draftId = $(await requireDraft(token, recordId, 'Only drafts can be edited or deleted')); const draft = $((await getDraft(token, draftId)).mapErr(notFoundAs(recordId))); const {payload, threadId} = draft.message; - if (attachmentNames(payload).length > 0) { + if (attachmentsOf(payload).length > 0) { return $(Err(ApiErr.validationError('This draft has attachments, which editing it here would drop; edit it in Gmail'))); } const current = {to: headerOf(payload, 'To'), subject: headerOf(payload, 'Subject'), body: messageBody(payload)}; @@ -423,6 +425,36 @@ export const replyToGmail: MailReply = (target, recordId, reply) => }), ); +// A message whole, by an email's id: a draft's by its draft id. +function messageOf(token: string, recordId: string): Promise> { + return Do(async ($) => { + const ref = $(requireRef(recordId)); + if (ref.kind === 'draft') { + return $((await getDraft(token, ref.draftId)).mapErr(notFoundAs(recordId))).message; + } + return $((await getMessage(token, ref.messageId)).mapErr(notFoundAs(recordId))); + }); +} + +/** One of an email's attachments, read as text. */ +const readAttachment: NewCommandRunner = (target, values) => + Do(async ($) => { + const email = $(requireEmailId(values)); + return $( + await callGoogle(target.connection, (token) => + Do(async ($$) => { + const message = $$(await messageOf(token, email)); + const {attachment, number} = $$(pickAttachment(attachmentsOf(message.payload), values)); + $$(readableKind(attachment)); + const bytes = attachment.attachmentId ? $$(await getAttachment(token, message.id, attachment.attachmentId)) : Buffer.from(attachment.data ?? '', 'base64url'); + return $$(await attachmentRecord(email, number, {...attachment, bytes})); + }), + ), + ); + }); + +export const gmailAttachmentsConnector: Connector = {...onlyRun('Attachments are only read, one at a time'), newCommands: {read: readAttachment}}; + export const gmailConnector: Connector = { list: listPage, get: (target, recordId) => callGoogle(target.connection, (token) => readRecord(token, target.connection.account, recordId)), diff --git a/packages/api/src/utils/decode-text.ts b/packages/api/src/utils/decode-text.ts new file mode 100644 index 00000000..52c027f6 --- /dev/null +++ b/packages/api/src/utils/decode-text.ts @@ -0,0 +1,18 @@ +import {Result} from 'ts-results-es'; + +const UTF8 = new TextDecoder('utf-8', {fatal: true}); + +/** + * Text from its bytes: as UTF-8 where they are UTF-8, else in the charset named, Windows-1252 + * where none is named or TextDecoder doesn't know it. Bytes that read as UTF-8 are taken as UTF-8 + * whatever charset is named, since text in another charset seldom is: a part's content can come + * as UTF-8 while its Content-Type names another charset, as from Gmail, and read as that charset + * turns ä into ä. + */ +export function decodeText(bytes: Uint8Array, charset: string | null): string { + const utf8 = Result.wrap(() => UTF8.decode(bytes)); + if (utf8.isOk()) { + return utf8.value; + } + return Result.wrap(() => new TextDecoder(charset ?? 'windows-1252').decode(bytes)).unwrapOr(new TextDecoder('windows-1252').decode(bytes)); +} diff --git a/packages/api/src/utils/html-to-text.test.ts b/packages/api/src/utils/html-to-text.test.ts new file mode 100644 index 00000000..dd8f8561 --- /dev/null +++ b/packages/api/src/utils/html-to-text.test.ts @@ -0,0 +1,30 @@ +import {expect, test} from 'bun:test'; + +import {htmlToText} from './html-to-text'; + +test('htmlToText finds the end of a hidden element after a letter that lowercases to two', () => { + expect(htmlToText('

İstanbul

after

')).toBe('İstanbul\nafter'); + expect(htmlToText(`

${'İ'.repeat(12)} before

after

more

`)).toBe(`${'İ'.repeat(12)} before\nafter\nmore`); +}); + +test('htmlToText decodes entities only once the tags are gone', () => { + expect(htmlToText('

<b> is bold — ☺

')).toBe(' is bold — ☺'); +}); + +test('htmlToText reads a number Unicode has no character for as the replacement character', () => { + expect(htmlToText('

a�b�c�d

')).toBe('a\uFFFDb\uFFFDc\uFFFDd'); +}); + +test('htmlToText keeps the text of unclosed tags, and of a link that never ends', () => { + expect(htmlToText('

Hi there', open + 4); + at = end === -1 ? html.length : end + 3; + continue; + } + const close = html.indexOf('>', open); + if (close === -1) { + parts.push(sourceText(html.slice(open))); + break; + } + const tag = html.slice(open, close + 1); + at = close + 1; + const [, slash = '', rawName = ''] = /^<(\/?)([a-z][a-z0-9]*)/i.exec(tag) ?? []; + const name = rawName.toLowerCase(); + const closing = slash === '/'; + if (!closing && HIDDEN.includes(name)) { + const end = indexOfTag(html, `', end); + at = endClose === -1 ? html.length : endClose + 1; + continue; + } + if (name === 'a' && !closing) { + const href = /\shref\s*=\s*"([^"]*)"/i.exec(tag)?.[1]; + link = href === undefined ? null : {href, start: parts.length}; + continue; + } + if (name === 'a' && closing && link) { + const label = parts.splice(link.start).join('').trim(); + parts.push(label === '' || label === link.href ? link.href : `${label} (${link.href})`); + link = null; + continue; + } + parts.push(tagText(name, closing)); + } + return decodeEntities(parts.join('')) + .replace(/[ \t\u00a0]+/g, ' ') + .replace(/ ?\n ?/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +// Text between tags: a line break in the HTML's source is a space, as a browser shows it. +function sourceText(text: string): string { + return text.replace(/\r\n?|\n/g, ' '); +} + +// Where a tag next starts from `from`, whatever its case. The email isn't lowercased whole to look, +// since a letter can lowercase to more than one, as İ does, which would move everything after it. +function indexOfTag(html: string, tag: string, from: number): number { + const pattern = new RegExp(tag, 'ig'); + pattern.lastIndex = from; + return pattern.exec(html)?.index ?? -1; +} diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 206843c9..31aa582f 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -510,6 +510,13 @@ const RANGE_TITLES: [string, ...string[]] = [ "'Trip costs'!A1:D9", ]; +// The attachments agents read. +const ATTACHMENT_TITLES: [string, ...string[]] = [ + 'Q3 budget.xlsx', + 'Invoice 1042.pdf', + 'Lease.docx', +]; + // What agents search for. const SEARCHES: [string, ...string[]] = [ 'invoice', @@ -521,6 +528,10 @@ const SEARCHES: [string, ...string[]] = [ ]; function recordTitle(connection: Connection, collectionId: string): string { + if (collectionId === 'attachments') { + return pick(ATTACHMENT_TITLES); + } + if (connection.integrationId === 'email' || collectionId === 'emails') { return pick(EMAIL_TITLES); } diff --git a/packages/docs/content/connections/email/index.mdx b/packages/docs/content/connections/email/index.mdx index 2a8608cd..0f0e87c3 100644 --- a/packages/docs/content/connections/email/index.mdx +++ b/packages/docs/content/connections/email/index.mdx @@ -12,7 +12,10 @@ Personal Agent Proxy and nothing else. Emails stay with the provider; nothing is Agents see the inbox, drafts and sent mail as one list. You can allow each action separately: - **Read** lists, opens and searches email. A mailbox over IMAP has no web address for an email, - so an agent points you to one by its subject, sender and date rather than a link. + so an agent points you to one by its subject, sender and date rather than a link. An email opens + with its attachments listed, and an attachment is read as text: a spreadsheet's tabs as CSV, a + PDF's or a Word document's text, a forwarded email, or a text file as it is, the first 100,000 + characters of it. Images and other files can't be read. - **Mark read or unread**, **Flag**, **Archive** and **Move to Trash** act on messages in the inbox, and require Read. Trash can still be restored at the provider. - **Write drafts** creates, edits and deletes drafts, and requires Read. Received messages are diff --git a/packages/docs/content/connections/google.mdx b/packages/docs/content/connections/google.mdx index c3149da4..aab546c8 100644 --- a/packages/docs/content/connections/google.mdx +++ b/packages/docs/content/connections/google.mdx @@ -136,8 +136,10 @@ A search is Gmail's own, as in its search box, such as `from:sam has:attachment can allow each action separately: - **Read email** lists, opens and searches email. An email opens as its text, or its HTML as text - where it has none, with the names of its attachments. Each comes with its link in Gmail, in this - account, for the agent to give you; a draft's opens it to be written. + where it has none, with its attachments listed. Each comes with its link in Gmail, in this + account, for the agent to give you; a draft's opens it to be written. An attachment is read as + text: a spreadsheet's tabs as CSV, a PDF's or a Word document's text, a forwarded email, or a + text file as it is, the first 100,000 characters of it. Images and other files can't be read. - **Mark read or unread**, **Star**, **Archive** and **Move email to Trash** act on messages in the inbox, and require Read email. Archived email stays in All Mail, and Gmail keeps Trash 30 days before deleting it; nothing is deleted for good. diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index 2557f6b1..78e7ec95 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -147,6 +147,47 @@ function emailsCollection(args: { // "Unread, Flagged" ("Unread, Starred" on Gmail), for an email in the inbox. {key: 'status', label: 'Status', type: 'text', system: true}, {key: 'body', label: 'Body', type: 'longtext'}, + // One a line, numbered as the attachments collection reads them by: "1. Q3.xlsx + // (application/…, 48 KB)". + {key: 'attachments', label: 'Attachments', type: 'longtext', system: true}, + ], + }; +} + +/** + * An email's attachments, read as text with the action reading the email: never listed or opened, + * only read, one at a time, by the email's id and the attachment's number among those the email + * lists, or its name. + */ +function attachmentsCollection(read: string): Collection { + return { + id: 'attachments', + name: 'Attachments', + singular: 'attachment', + titleField: 'name', + writes: {}, + commands: [ + { + id: 'read', + on: 'new', + label: 'Read', + description: + 'Read one of an email’s attachments as text: a spreadsheet’s tabs as CSV, a PDF’s or a Word document’s text, a forwarded email, or text as it is. Name the email by its id, as a list gives it, and the attachment by its number among the attachments the email lists, or its name. Images and other files are turned down.', + action: read, + readOnly: true, + done: 'Read {}', + tried: 'read {}', + }, + ], + fields: [ + {key: 'email', label: 'Email', type: 'text'}, + {key: 'attachment', label: 'Attachment', type: 'text'}, + {key: 'name', label: 'Name', type: 'text', system: true}, + {key: 'type', label: 'Type', type: 'text', system: true}, + {key: 'size', label: 'Size', type: 'text', system: true}, + // "1234 characters", or how many of more were answered: at most 100,000. + {key: 'length', label: 'Length', type: 'text', system: true}, + {key: 'content', label: 'Content', type: 'longtext', system: true}, ], }; } @@ -226,6 +267,7 @@ const email: Integration = { 'On Gmail, Gmail’s own search syntax works, such as from:sam has:attachment newer_than:7d. Other mailboxes match the words in the sender, recipients, subject and text.', flag: FLAG, }), + attachmentsCollection('read'), ], }; @@ -1032,6 +1074,7 @@ const google: Integration = { flag: STAR, openIn: 'Gmail', }), + attachmentsCollection('readEmails'), // The calendars the account sees, each holding its events: from now on, soonest first, every // time a repeating event comes round its own record. Times are written as Google takes them, // 2026-10-15T14:00 in the calendar's time zone or with an offset, and a day alone, 2026-10-15, diff --git a/packages/integrations/src/tools.test.ts b/packages/integrations/src/tools.test.ts index 1d08204e..39b6ce41 100644 --- a/packages/integrations/src/tools.test.ts +++ b/packages/integrations/src/tools.test.ts @@ -31,6 +31,7 @@ describe('integrationTools', () => { 'emails_trash', 'emails_send', 'emails_send_new', + 'attachments_read', ]); expect(integrationTools(integration('granola')).map((tool) => tool.name)).toEqual([ 'notes_list', @@ -96,6 +97,18 @@ describe('integrationTools', () => { }); }); +test('an attachment is read with the action reading email, by the email and the attachment, as the command describes it', () => { + const read = findTool(email, 'attachments_read'); + + expect(read).toMatchObject({kind: 'newCommand', action: 'read', readOnly: true}); + expect(read?.description).toStartWith('Read one of an email’s attachments as text'); + expect(Object.keys(read?.inputSchema.properties ?? {})).toEqual(['email', 'attachment']); + expect(findTool(integration('google'), 'attachments_read')).toMatchObject({ + action: 'readEmails', + readOnly: true, + }); +}); + describe('allowedTools', () => { test('only the tools whose action the agent has', () => { expect(allowedTools(email, ['send']).map((tool) => tool.name)).toEqual([ diff --git a/packages/integrations/src/tools.ts b/packages/integrations/src/tools.ts index 1d02826f..f30d4019 100644 --- a/packages/integrations/src/tools.ts +++ b/packages/integrations/src/tools.ts @@ -270,7 +270,9 @@ function collectionTools(integration: Integration, collection: Collection): Coll if (command.on === 'new') { return make('newCommand', command.id, { title: command.label, - description: `${command.label} a new ${collection.singular} from the fields given, without saving it first.${what}`, + description: + command.description ?? + `${command.label} a new ${collection.singular} from the fields given, without saving it first.${what}`, inputSchema: objectSchema(valueProperties(integration, collection)), readOnly: command.readOnly, }); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 2d98c4e2..9814262d 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -46,6 +46,9 @@ export type Command = { on: 'record' | 'new'; // The button: "Mark as read". label: string; + // What an agent is told the tool does, where the label and the action don't say it, as + // reading an attachment needs the email and the attachment named. + description?: string; action: string; // The records it applies to, when not every one. where?: Condition;