this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: fix the bundler

+421 -478
+26 -18
README.md
··· 2 2 3 3 Intelligent email classifier that automatically filters college marketing spam while keeping important emails in your inbox. 4 4 5 - **Current Performance**: 100% accuracy on 58 labeled emails 5 + **Current Performance**: 100% accuracy on 63 labeled emails 6 6 7 7 ## Quick Start 8 8 ··· 45 45 **TypeScript → Google Apps Script Pipeline** 46 46 47 47 ``` 48 - src/apps-script/Code.ts (TypeScript with type safety) 48 + src/classifier.ts (Core classification logic - single source of truth) 49 + 50 + ↓ imported by 49 51 50 - ↓ bun run gas build (compile) 52 + src/apps-script/wrapper.ts (Apps Script wrapper) 51 53 52 - build/Code.gs (Google Apps Script) 54 + ↓ bun run gas (bundle with esbuild) 55 + 56 + build/Code.gs (Single-file Google Apps Script) 53 57 54 58 ↓ Manual copy/paste 55 59 56 60 Google Apps Script → Gmail Auto-Filtering 57 61 ``` 62 + 63 + **Key Improvement**: The classifier logic is now shared between local testing and Apps Script deployment. No more manual syncing! 58 64 59 65 ## Gmail Deployment 60 66 ··· 91 97 # 3. Import and evaluate 92 98 bun run import new-emails-labeled.json 93 99 94 - # 4. Update patterns in src/apps-script/Code.ts 100 + # 4. Update patterns in src/classifier.ts 95 101 96 102 # 5. Test locally 97 103 bun test ··· 103 109 104 110 ### Adding New Patterns 105 111 106 - 1. Edit `src/apps-script/Code.ts` with type-safe TypeScript 112 + 1. Edit `src/classifier.ts` - the single source of truth 107 113 2. Add tests in `src/classifier.test.ts` 108 114 3. Run `bun test` to verify 109 115 4. Build and deploy: `bun run gas build` then copy to Apps Script 110 116 111 - **Note**: Keep `src/classifier.ts` (local testing) and `src/apps-script/Code.ts` (deployed) in sync manually. 117 + **Note**: The Apps Script is automatically bundled from `src/classifier.ts` via `src/apps-script/wrapper.ts` 112 118 113 119 ## Project Structure 114 120 115 121 ``` 116 122 src/ 117 123 apps-script/ 118 - Code.ts - Apps Script source (TypeScript) 119 - appsscript.json - Apps Script manifest 120 - classifier.ts - Core classifier (for local testing) 124 + wrapper.ts - Apps Script wrapper (imports classifier) 125 + Code.ts - DEPRECATED (see wrapper.ts) 126 + appsscript.json - Apps Script manifest 127 + classifier.ts - Core classifier (SINGLE SOURCE OF TRUTH) 121 128 classifier.test.ts - Unit tests 122 129 types.ts - TypeScript types 123 130 evaluate.ts - Evaluation tool 124 131 label.ts - Interactive labeling CLI 125 132 import-labeled.ts - Import labeled emails 126 - build-gas.ts - Build/deploy script 133 + build-gas.ts - Build/deploy script (uses esbuild) 127 134 128 135 scripts/ 129 136 export-from-label.gs - Export emails from Gmail 130 137 131 138 build/ - Generated (gitignored) 132 - Code.gs - Compiled Apps Script 133 - compiled/ - Intermediate JavaScript 139 + Code.gs - Bundled Apps Script 140 + bundled.js - Intermediate bundle 134 141 135 142 data/ 136 - labeled-emails.json - Main dataset (58 emails) 143 + labeled-emails.json - Main dataset (63 emails) 137 144 example-export.json - Example export 138 145 139 146 tsconfig.apps-script.json - TypeScript config for Apps Script ··· 162 169 - **Modern syntax**: ES6+ features (arrow functions, classes, etc.) 163 170 - **Local development**: Edit with VS Code autocomplete 164 171 - **Manual deployment**: Build locally, copy/paste to Apps Script 165 - - **No bundler overhead**: Simple TypeScript → JavaScript compilation 172 + - **Bundling**: esbuild bundles classifier + wrapper into single file 173 + - **Single source of truth**: `src/classifier.ts` used by both local tests and Apps Script 166 174 167 175 Configuration: 168 - - `tsconfig.apps-script.json` - Targets ES2015, no modules 169 - - `src/build-gas.ts` - Build script 176 + - `src/build-gas.ts` - Build script with esbuild bundler 177 + - `src/apps-script/wrapper.ts` - Apps Script wrapper that imports classifier 170 178 171 179 ## Metrics 172 180 173 - - **Accuracy**: 100% (58/58 emails correctly classified) 181 + - **Accuracy**: 100% (63/63 emails correctly classified) 174 182 - **Precision**: 100% (no false positives - no spam in inbox) 175 183 - **Recall**: 100% (no false negatives - all important emails reach inbox) 176 184 - **F1 Score**: 100%
+55
bun.lock
··· 10 10 "devDependencies": { 11 11 "@google/clasp": "^3.1.3", 12 12 "@types/google-apps-script": "^2.0.8", 13 + "esbuild": "^0.27.1", 13 14 "ts2gas": "^4.2.0", 14 15 "typescript": "^5.9.3", 15 16 }, ··· 19 20 "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], 20 21 21 22 "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], 23 + 24 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="], 25 + 26 + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.1", "", { "os": "android", "cpu": "arm" }, "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg=="], 27 + 28 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.1", "", { "os": "android", "cpu": "arm64" }, "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ=="], 29 + 30 + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.1", "", { "os": "android", "cpu": "x64" }, "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ=="], 31 + 32 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ=="], 33 + 34 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ=="], 35 + 36 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg=="], 37 + 38 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ=="], 39 + 40 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.1", "", { "os": "linux", "cpu": "arm" }, "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA=="], 41 + 42 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q=="], 43 + 44 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw=="], 45 + 46 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg=="], 47 + 48 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA=="], 49 + 50 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ=="], 51 + 52 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.1", "", { "os": "linux", "cpu": "none" }, "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ=="], 53 + 54 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw=="], 55 + 56 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.1", "", { "os": "linux", "cpu": "x64" }, "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA=="], 57 + 58 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ=="], 59 + 60 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.1", "", { "os": "none", "cpu": "x64" }, "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg=="], 61 + 62 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g=="], 63 + 64 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg=="], 65 + 66 + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg=="], 67 + 68 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA=="], 69 + 70 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg=="], 71 + 72 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ=="], 73 + 74 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.1", "", { "os": "win32", "cpu": "x64" }, "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw=="], 22 75 23 76 "@formatjs/ecma402-abstract": ["@formatjs/ecma402-abstract@2.3.6", "", { "dependencies": { "@formatjs/fast-memoize": "2.2.7", "@formatjs/intl-localematcher": "0.6.2", "decimal.js": "^10.4.3", "tslib": "^2.8.0" } }, "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw=="], 24 77 ··· 177 230 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 178 231 179 232 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 233 + 234 + "esbuild": ["esbuild@0.27.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.1", "@esbuild/android-arm": "0.27.1", "@esbuild/android-arm64": "0.27.1", "@esbuild/android-x64": "0.27.1", "@esbuild/darwin-arm64": "0.27.1", "@esbuild/darwin-x64": "0.27.1", "@esbuild/freebsd-arm64": "0.27.1", "@esbuild/freebsd-x64": "0.27.1", "@esbuild/linux-arm": "0.27.1", "@esbuild/linux-arm64": "0.27.1", "@esbuild/linux-ia32": "0.27.1", "@esbuild/linux-loong64": "0.27.1", "@esbuild/linux-mips64el": "0.27.1", "@esbuild/linux-ppc64": "0.27.1", "@esbuild/linux-riscv64": "0.27.1", "@esbuild/linux-s390x": "0.27.1", "@esbuild/linux-x64": "0.27.1", "@esbuild/netbsd-arm64": "0.27.1", "@esbuild/netbsd-x64": "0.27.1", "@esbuild/openbsd-arm64": "0.27.1", "@esbuild/openbsd-x64": "0.27.1", "@esbuild/openharmony-arm64": "0.27.1", "@esbuild/sunos-x64": "0.27.1", "@esbuild/win32-arm64": "0.27.1", "@esbuild/win32-ia32": "0.27.1", "@esbuild/win32-x64": "0.27.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA=="], 180 235 181 236 "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], 182 237
+88 -3
data/labeled-emails.json
··· 1 1 { 2 - "exported_at": "2025-12-07T16:36:04.975Z", 3 - "total_count": 58, 2 + "exported_at": "2025-12-08T17:47:25.257Z", 3 + "total_count": 63, 4 4 "label": "College/Auto", 5 5 "emails": [ 6 6 { ··· 988 988 "reason": "Marketing/unsolicited outreach", 989 989 "confidence": "high", 990 990 "labeled_at": "2025-12-07T16:36:04.975Z" 991 + }, 992 + { 993 + "thread_id": "19afeb02bf7cfb20", 994 + "subject": "Re: Your admission decision at Saint Xavier University", 995 + "from": "Saint Xavier University <SaintXavierUniversity_at_gosaintxavier.org_l9k069g0@duck.com>", 996 + "to": "Kieran Klukas <l9k069g0@duck.com>", 997 + "cc": "", 998 + "date": "2025-12-08T15:38:56.000Z", 999 + "body": "Your status is proof that you've impressed our admission team ...\r\n\r\n<div class=\"dynamic-content\"><p>Dear Kieran,</p><p>Your <strong>Priority Student&nbsp;</strong>status is proof that you've impressed our admission team at Saint Xavier University, and the advantages that come with that status demonstrate that we're interested in your candidacy.</p><p>If you haven't yet done so, <a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEwZGQ3NDkwMjM0ODk5OTkyIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzMzO3M6NDoibGVhZCI7czo3OiI0MDQ4MzkxIjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTVmMTY2YWVhLWQzNTktNGYyZS05MGIwLWQ2YTViNTFjYzU5NyI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">submit the <strong>Priority Student Application</strong> we've created for you here</a> or <a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEwZGQ3NDkwMjM0ODk5OTkyIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzMzO3M6NDoibGVhZCI7czo3OiI0MDQ4MzkxIjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9NWYxNjZhZWEtZDM1OS00ZjJlLTkwYjAtZDZhNWI1MWNjNTk3Ijt9&\" rel=\"noopener\" target=\"_blank\">add SXU to the Common App</a>. The deadline to apply is <strong>December 15</strong><strong>,</strong> but that should be no problem. Your advantages are seriously time-saving. You'll:</p><ul><li>Receive <strong>an annual scholarship worth up to $24,000</strong> upon admission.</li><li>Pay <strong>no</strong> application fee.</li><li>Write <strong>no</strong> essay (unless you are a nursing applicant).</li><li>Submit <strong>no</strong> test scores unless you want to.</li></ul><p>We want it to be simple to complete our application, Kieran, and we want our top-tier education to be within your reach. That's why we offer a wide range of scholarships and awards to qualified students—and why we're happy to consider you for those awards automatically when you apply.</p><p><a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEwZGQ3NDkwMjM0ODk5OTkyIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzMzO3M6NDoibGVhZCI7czo3OiI0MDQ4MzkxIjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTVmMTY2YWVhLWQzNTktNGYyZS05MGIwLWQ2YTViNTFjYzU5NyI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">Submit the <strong>Priority Student Application</strong> here.</a></p><p><a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEwZGQ3NDkwMjM0ODk5OTkyIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzMzO3M6NDoibGVhZCI7czo3OiI0MDQ4MzkxIjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9NWYxNjZhZWEtZDM1OS00ZjJlLTkwYjAtZDZhNWI1MWNjNTk3Ijt9&\" rel=\"noopener\" target=\"_blank\">Or add SXU to the Common App here.</a></p><p>I hope you'll take a few moments to submit your application right now, Kieran! I look forward to learning even more about you.</p><p>Sincerely,</p><p><div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 17px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><strong>Brian D. Hotzfield</strong></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 16px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><i>Vice President - Enrollment</i></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">Saint Xavier University</div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">3700 West 103rd Street<br />\r\nChicago, IL 60655</div></p></div>\r\n\r\nWe received your information from\r\nyour Appily college research.\r\n\r\nBrowse to https://my.gosaintxavier.org/email/unsubscribe/6936f10dd7490234899992/l9k069g0@duck.com/058590bcf0737eef4c4d9f35b95dab6a290be2dfa9e70822ef7611cecf12a5fe to no longer receive emails from this company.\r\n", 1000 + "labels": [ 1001 + "College" 1002 + ], 1003 + "is_in_inbox": true, 1004 + "pertains": false, 1005 + "reason": "Saint Xavier spam", 1006 + "labeled_at": "2025-12-08T17:47:21.132Z", 1007 + "confidence": "high" 1008 + }, 1009 + { 1010 + "thread_id": "19afea144e0e49d2", 1011 + "subject": "Re: Your admission decision at Saint Xavier University", 1012 + "from": "Saint Xavier University <SaintXavierUniversity_at_gosaintxavier.org_3rmhb7wb@duck.com>", 1013 + "to": "Kieran Klukas <3rmhb7wb@duck.com>", 1014 + "cc": "", 1015 + "date": "2025-12-08T15:42:49.000Z", 1016 + "body": "Your status is proof that you've impressed our admission team ...\r\n\r\n<div class=\"dynamic-content\"><p>Dear Kieran,</p><p>Your <strong>Priority Student&nbsp;</strong>status is proof that you've impressed our admission team at Saint Xavier University, and the advantages that come with that status demonstrate that we're interested in your candidacy.</p><p>If you haven't yet done so, <a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjFmNzk3YzQ3MzMxMDEyOTEzIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4NTY3O3M6NDoibGVhZCI7czo3OiI0MDk2NDI5IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTJkYTZhZGRlLTRmODQtNDQ1OC1hZGZjLTgzZDVjNWQ4YjNjNyI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">submit the <strong>Priority Student Application</strong> we've created for you here</a> or <a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjFmNzk3YzQ3MzMxMDEyOTEzIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4NTY3O3M6NDoibGVhZCI7czo3OiI0MDk2NDI5IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9MmRhNmFkZGUtNGY4NC00NDU4LWFkZmMtODNkNWM1ZDhiM2M3Ijt9&\" rel=\"noopener\" target=\"_blank\">add SXU to the Common App</a>. The deadline to apply is <strong>December 15</strong><strong>,</strong> but that should be no problem. Your advantages are seriously time-saving. You'll:</p><ul><li>Receive <strong>an annual scholarship worth up to $24,000</strong> upon admission.</li><li>Pay <strong>no</strong> application fee.</li><li>Write <strong>no</strong> essay (unless you are a nursing applicant).</li><li>Submit <strong>no</strong> test scores unless you want to.</li></ul><p>We want it to be simple to complete our application, Kieran, and we want our top-tier education to be within your reach. That's why we offer a wide range of scholarships and awards to qualified students—and why we're happy to consider you for those awards automatically when you apply.</p><p><a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjFmNzk3YzQ3MzMxMDEyOTEzIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4NTY3O3M6NDoibGVhZCI7czo3OiI0MDk2NDI5IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTJkYTZhZGRlLTRmODQtNDQ1OC1hZGZjLTgzZDVjNWQ4YjNjNyI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">Submit the <strong>Priority Student Application</strong> here.</a></p><p><a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjFmNzk3YzQ3MzMxMDEyOTEzIjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4NTY3O3M6NDoibGVhZCI7czo3OiI0MDk2NDI5IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9MmRhNmFkZGUtNGY4NC00NDU4LWFkZmMtODNkNWM1ZDhiM2M3Ijt9&\" rel=\"noopener\" target=\"_blank\">Or add SXU to the Common App here.</a></p><p>I hope you'll take a few moments to submit your application right now, Kieran! I look forward to learning even more about you.</p><p>Sincerely,</p><p><div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 17px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><strong>Brian D. Hotzfield</strong></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 16px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><i>Vice President - Enrollment</i></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">Saint Xavier University</div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">3700 West 103rd Street<br />\r\nChicago, IL 60655</div></p></div>\r\n\r\nWe received your information from\r\nthe College Board's Student Search Service.\r\n\r\nBrowse to https://my.gosaintxavier.org/email/unsubscribe/6936f1f797c47331012913/3rmhb7wb@duck.com/ec3770a0580ffa905c5c0aa5eb189f0af3764ad8cb445950c926484d62b8a9a3 to no longer receive emails from this company.\r\n", 1017 + "labels": [ 1018 + "College" 1019 + ], 1020 + "is_in_inbox": true, 1021 + "pertains": false, 1022 + "reason": "Saint Xavier spam", 1023 + "labeled_at": "2025-12-08T17:47:21.133Z", 1024 + "confidence": "high" 1025 + }, 1026 + { 1027 + "thread_id": "19afe9e19288e1fd", 1028 + "subject": "Re: Your admission decision at Saint Xavier University", 1029 + "from": "Saint Xavier University <SaintXavierUniversity_at_gosaintxavier.org_canopy-rind-aloft@duck.com>", 1030 + "to": "Kieran Klukas <canopy-rind-aloft@duck.com>", 1031 + "cc": "", 1032 + "date": "2025-12-08T15:39:22.000Z", 1033 + "body": "Your status is proof that you've impressed our admission team ...\r\n\r\n<div class=\"dynamic-content\"><p>Dear Kieran,</p><p>Your <strong>Priority Student&nbsp;</strong>status is proof that you've impressed our admission team at Saint Xavier University, and the advantages that come with that status demonstrate that we're interested in your candidacy.</p><p>If you haven't yet done so, <a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEyNjgzNTJhODk0MzM2MTE0IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzU4O3M6NDoibGVhZCI7czo3OiI0MDQ5ODI4IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPThkZmIyYzRmLTg4NzQtNDdiNC1hYzZiLTZlOWNjMDcxN2MzZiI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">submit the <strong>Priority Student Application</strong> we've created for you here</a> or <a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEyNjgzNTJhODk0MzM2MTE0IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzU4O3M6NDoibGVhZCI7czo3OiI0MDQ5ODI4IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9OGRmYjJjNGYtODg3NC00N2I0LWFjNmItNmU5Y2MwNzE3YzNmIjt9&\" rel=\"noopener\" target=\"_blank\">add SXU to the Common App</a>. The deadline to apply is <strong>December 15</strong><strong>,</strong> but that should be no problem. Your advantages are seriously time-saving. You'll:</p><ul><li>Receive <strong>an annual scholarship worth up to $24,000</strong> upon admission.</li><li>Pay <strong>no</strong> application fee.</li><li>Write <strong>no</strong> essay (unless you are a nursing applicant).</li><li>Submit <strong>no</strong> test scores unless you want to.</li></ul><p>We want it to be simple to complete our application, Kieran, and we want our top-tier education to be within your reach. That's why we offer a wide range of scholarships and awards to qualified students—and why we're happy to consider you for those awards automatically when you apply.</p><p><a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEyNjgzNTJhODk0MzM2MTE0IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzU4O3M6NDoibGVhZCI7czo3OiI0MDQ5ODI4IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPThkZmIyYzRmLTg4NzQtNDdiNC1hYzZiLTZlOWNjMDcxN2MzZiI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">Submit the <strong>Priority Student Application</strong> here.</a></p><p><a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjEyNjgzNTJhODk0MzM2MTE0IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MzU4O3M6NDoibGVhZCI7czo3OiI0MDQ5ODI4IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9OGRmYjJjNGYtODg3NC00N2I0LWFjNmItNmU5Y2MwNzE3YzNmIjt9&\" rel=\"noopener\" target=\"_blank\">Or add SXU to the Common App here.</a></p><p>I hope you'll take a few moments to submit your application right now, Kieran! I look forward to learning even more about you.</p><p>Sincerely,</p><p><div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 17px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><strong>Brian D. Hotzfield</strong></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 16px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><i>Vice President - Enrollment</i></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">Saint Xavier University</div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">3700 West 103rd Street<br />\r\nChicago, IL 60655</div></p></div>\r\n\r\nWe received your information from\r\nyour Appily college research.\r\n\r\nBrowse to https://my.gosaintxavier.org/email/unsubscribe/6936f1268352a894336114/canopy-rind-aloft@duck.com/1e9968b0f7a1a311a836deb2fb20328fd9237987914de2229216101fabe9e410 to no longer receive emails from this company.\r\n", 1034 + "labels": [ 1035 + "College" 1036 + ], 1037 + "is_in_inbox": true, 1038 + "pertains": false, 1039 + "reason": "Saint Xavier spam", 1040 + "labeled_at": "2025-12-08T17:47:21.133Z", 1041 + "confidence": "high" 1042 + }, 1043 + { 1044 + "thread_id": "19afe9c44a1c6a1a", 1045 + "subject": "Re: Your admission decision at Saint Xavier University", 1046 + "from": "Saint Xavier University <SaintXavierUniversity_at_gosaintxavier.org_pulp-flint-maybe@duck.com>", 1047 + "to": "Kieran Klukas <pulp-flint-maybe@duck.com>", 1048 + "cc": "", 1049 + "date": "2025-12-08T15:37:22.000Z", 1050 + "body": "Your status is proof that you've impressed our admission team ...\r\n\r\n<div class=\"dynamic-content\"><p>Dear Kieran,</p><p>Your <strong>Priority Student&nbsp;</strong>status is proof that you've impressed our admission team at Saint Xavier University, and the advantages that come with that status demonstrate that we're interested in your candidacy.</p><p>If you haven't yet done so, <a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjBhZmJmODhlMjIyNzI1ODc3IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MjM5O3M6NDoibGVhZCI7czo3OiI0MDM4MTE2IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTYwNmE0Yjk0LTVjNzgtNDZlMS1hM2IwLTc0NDllYjg2MGI4YiI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">submit the <strong>Priority Student Application</strong> we've created for you here</a> or <a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjBhZmJmODhlMjIyNzI1ODc3IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MjM5O3M6NDoibGVhZCI7czo3OiI0MDM4MTE2IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9NjA2YTRiOTQtNWM3OC00NmUxLWEzYjAtNzQ0OWViODYwYjhiIjt9&\" rel=\"noopener\" target=\"_blank\">add SXU to the Common App</a>. The deadline to apply is <strong>December 15</strong><strong>,</strong> but that should be no problem. Your advantages are seriously time-saving. You'll:</p><ul><li>Receive <strong>an annual scholarship worth up to $24,000</strong> upon admission.</li><li>Pay <strong>no</strong> application fee.</li><li>Write <strong>no</strong> essay (unless you are a nursing applicant).</li><li>Submit <strong>no</strong> test scores unless you want to.</li></ul><p>We want it to be simple to complete our application, Kieran, and we want our top-tier education to be within your reach. That's why we offer a wide range of scholarships and awards to qualified students—and why we're happy to consider you for those awards automatically when you apply.</p><p><a href=\"https://my.gosaintxavier.org/f/r/d782e1d1ced69d7230dc062f2?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjBhZmJmODhlMjIyNzI1ODc3IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MjM5O3M6NDoibGVhZCI7czo3OiI0MDM4MTE2IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDQ6Imh0dHBzOi8vYXBwbGljYXRpb24uZ29zYWludHhhdmllci5vcmcvP3V0bV9jYW1wYWlnbj1kbCZ1dG1fc291cmNlPWVucm9sbDM2MF9hcHBseSZ1dG1fbWVkaXVtPWVtYWlsJnV0bV9jb250ZW50PWFwcGx5JnRubnQ9MGI4Y2QyNDQtMjQ4OC00MDAxLWEzYWYtYWM0MmU1NTM3YjY0JlVUTV9yY3JkPTYwNmE0Yjk0LTVjNzgtNDZlMS1hM2IwLTc0NDllYjg2MGI4YiI7fQ%3D%3D&\" rel=\"noopener\" target=\"_blank\">Submit the <strong>Priority Student Application</strong> here.</a></p><p><a href=\"https://my.gosaintxavier.org/f/r/0fa03438ddc9ca8c49b9656fc?ct=YTo3OntzOjY6InNvdXJjZSI7YTowOnt9czo1OiJlbWFpbCI7aToyNjA7czo0OiJzdGF0IjtzOjIyOiI2OTM2ZjBhZmJmODhlMjIyNzI1ODc3IjtzOjk6InNlbnRfdGltZSI7aToxNzY1MjA4MjM5O3M6NDoibGVhZCI7czo3OiI0MDM4MTE2IjtzOjc6ImNoYW5uZWwiO2E6MTp7czo1OiJlbWFpbCI7aToyNjA7fXM6MjQ6Im10Y19yZWRpcmVjdF9kZXN0aW5hdGlvbiI7czoyMDY6Imh0dHBzOi8vYXBwbHkuY29tbW9uYXBwLm9yZy9sb2dpbj9tYT05MTg%2FdXRtX2NhbXBhaWduPWRsJnV0bV9zb3VyY2U9ZW5yb2xsMzYwX2FwcGx5JnV0bV9tZWRpdW09ZW1haWwmdXRtX2NvbnRlbnQ9YXBwbHkmdG5udD0wYjhjZDI0NC0yNDg4LTQwMDEtYTNhZi1hYzQyZTU1MzdiNjQmVVRNX3JjcmQ9NjA2YTRiOTQtNWM3OC00NmUxLWEzYjAtNzQ0OWViODYwYjhiIjt9&\" rel=\"noopener\" target=\"_blank\">Or add SXU to the Common App here.</a></p><p>I hope you'll take a few moments to submit your application right now, Kieran! I look forward to learning even more about you.</p><p>Sincerely,</p><p><div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 17px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><strong>Brian D. Hotzfield</strong></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-bottom: 0px; text-align: left; line-height: 1.3; font-size: 16px; color: rgb(0, 0, 0); --darkreader-inline-color: #b4b2b0;\"><i>Vice President - Enrollment</i></div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">Saint Xavier University</div>\r\n\r\n<div data-darkreader-inline-color=\"\" style=\"margin-top: 0px; margin-bottom: 0px; color: rgb(0, 0, 0); font-size: 15px; line-height: 1.3; --darkreader-inline-color: #b4b2b0;\">3700 West 103rd Street<br />\r\nChicago, IL 60655</div></p></div>\r\n\r\nWe received your information from\r\nyour Appily college research.\r\n\r\nBrowse to https://my.gosaintxavier.org/email/unsubscribe/6936f0afbf88e222725877/pulp-flint-maybe@duck.com/6d5ec3a37a1794f6a6b9799f1225cc73cc74658dcea93313d2240510a1b1729a to no longer receive emails from this company.\r\n", 1051 + "labels": [ 1052 + "College" 1053 + ], 1054 + "is_in_inbox": true, 1055 + "pertains": false, 1056 + "reason": "Saint Xavier spam", 1057 + "labeled_at": "2025-12-08T17:47:21.133Z", 1058 + "confidence": "high" 1059 + }, 1060 + { 1061 + "thread_id": "19afadb81307ff62", 1062 + "subject": "Drake Scholarships & Financial Aid Opportunities", 1063 + "from": "Drake University <admission_at_drake.edu_jkgwnjk4@duck.com>", 1064 + "to": "jkgwnjk4@duck.com", 1065 + "cc": "", 1066 + "date": "2025-12-07T22:07:56.000Z", 1067 + "body": "Sign up now to learn more\r\n\r\n͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌  ͏‌   Drake offers a wide variety of scholarship and financial aid opportunities for you!\r\n\r\n\r\n Hello Kieran, \r\n\r\n The 2026-27 Free Application for Federal Student Aid (FAFSA) is now open. The FAFSA is required to be considered for grants (free money!) as well as eligibility for Federal Work Study or federal student loans. Plus, some scholarship opportunities at Drake also require a FAFSA submission. \r\n\r\n Here are two steps you can take now: \r\n\r\n\t1. File the FAFSA <http://studentaid.gov/h/apply-for-aid/fafsa> with Drake's school code (001860) to receive a financial aid offer this spring\r\n\t2. Join us on Zoom on December 16 for a live, informative virtual program about Drake's scholarships and financial aid opportunities. Details are below----reserve your spot! \r\n\r\nVirtual: Exploring Drake's Scholarship Opportunities and Financial Aid Programs\r\n\r\nTuesday, December 16\r\n 6pm Central Time\r\n Zoom\r\n\r\n In this session, Drake's admission and financial aid experts will provide an overview of the scholarship programs and application processes and the financial aid application process and timeline. \r\n\r\nPlease note: This will be an interactive, live virtual event and will not be recorded so that you and your family can ask your questions candidly! Don't miss it! \r\n\r\nSIGN UP: SCHOLARSHIP & FINANCIAL AID ZOOM <https://apply.drake.edu/register/?id=1e7a4882-83b9-494b-bae0-5859c4beb390&pid=f3xaFlTfujYC3gJSL1vEdMHTYfLD-KHP-c7hKXUECno0iHhYvScvsssoZU99I5BgI_HuVjusiP8Q9x3xTsGbloPaN9SFA8aozApbbG2A7LM>\r\n\r\n Investing in your future takes careful planning. Drake looks forward to assisting you with exploring sources for financial support. \r\n\r\n Go Bulldogs! \r\n\r\n\r\n\r\n\r\n\r\n2507 University Ave. \r\n Des Moines, IA 50311\r\n 515-271-2011 | drake.edu\r\n\r\n\r\n\r\n\r\n\r\n\"You may be receiving this email as an opt-in from the College Board Student Search Service\"\r\n\r\n\r\nThis email was sent to jkgwnjk4@duck.com by Drake University.\r\nDrake University, 2507 University Ave, Des Moines, IA 50311\r\nUnsubscribe from All Drake University Admission Communications. <https://apply.drake.edu/go?r=optout&mid=6cd6393e-5e20-466e-b190-57ba8799f352>\r\n", 1068 + "labels": [ 1069 + "College" 1070 + ], 1071 + "is_in_inbox": true, 1072 + "pertains": false, 1073 + "reason": "Drake scholarship spam", 1074 + "labeled_at": "2025-12-08T17:47:21.133Z", 1075 + "confidence": "high" 991 1076 } 992 1077 ] 993 - } 1078 + }
+1
package.json
··· 16 16 "devDependencies": { 17 17 "@google/clasp": "^3.1.3", 18 18 "@types/google-apps-script": "^2.0.8", 19 + "esbuild": "^0.27.1", 19 20 "ts2gas": "^4.2.0", 20 21 "typescript": "^5.9.3" 21 22 }
+9 -441
src/apps-script/Code.ts
··· 1 - // Apps Script classifier - compiled from TypeScript 2 - // This file is the source of truth for Gmail filtering logic 3 - 4 - // Configuration 5 - const AUTO_LABEL_NAME = "College/Auto"; 6 - const FILTERED_LABEL_NAME = "College/Filtered"; 7 - const APPROVED_LABEL_NAME = "College"; 8 - const DRY_RUN = true; 9 - 10 - const AI_BASE_URL = "https://ai.hackclub.com/proxy/v1/chat/completions"; 11 - const AI_MODEL = "deepseek/deepseek-r1-distill-qwen-32b"; 12 - 13 - const MAX_THREADS_PER_RUN = 75; 14 - const MAX_EXECUTION_TIME_MS = 4.5 * 60 * 1000; 15 - const GMAIL_BATCH_SIZE = 20; 16 - const AI_CONFIDENCE_THRESHOLD = 0.5; 17 - 18 - // Main entry points 19 - function ensureLabels(): void { 20 - getOrCreateLabel(AUTO_LABEL_NAME); 21 - getOrCreateLabel(FILTERED_LABEL_NAME); 22 - getOrCreateLabel(APPROVED_LABEL_NAME); 23 - Logger.log(`Labels ensured: ${AUTO_LABEL_NAME}, ${FILTERED_LABEL_NAME}, ${APPROVED_LABEL_NAME}`); 24 - } 25 - 26 - function runTriage(): void { 27 - const startTime = Date.now(); 28 - const autoLabel = getOrCreateLabel(AUTO_LABEL_NAME); 29 - const filteredLabel = getOrCreateLabel(FILTERED_LABEL_NAME); 30 - const approvedLabel = getOrCreateLabel(APPROVED_LABEL_NAME); 31 - 32 - const threads = autoLabel.getThreads(0, MAX_THREADS_PER_RUN); 33 - if (!threads.length) { 34 - Logger.log("No threads under College/Auto."); 35 - return; 36 - } 37 - 38 - Logger.log(`Processing ${threads.length} threads`); 39 - 40 - let stats = { 41 - wouldInbox: 0, 42 - wouldFiltered: 0, 43 - didInbox: 0, 44 - didFiltered: 0, 45 - errors: 0, 46 - skipped: 0 47 - }; 48 - 49 - for (let i = 0; i < threads.length; i++) { 50 - const elapsed = Date.now() - startTime; 51 - if (elapsed > MAX_EXECUTION_TIME_MS) { 52 - Logger.log(`Time limit reached. Processed ${i}/${threads.length}`); 53 - stats.skipped = threads.length - i; 54 - break; 55 - } 56 - 57 - const thread = threads[i]; 58 - 59 - try { 60 - processThread(thread, autoLabel, approvedLabel, filteredLabel, stats); 61 - } catch (e) { 62 - Logger.log(`ERROR: ${e}. FAIL-SAFE: Moving to inbox.`); 63 - stats.errors += 1; 64 - 65 - if (!DRY_RUN) { 66 - thread.removeLabel(autoLabel); 67 - thread.removeLabel(filteredLabel); 68 - thread.moveToInbox(); 69 - stats.didInbox += 1; 70 - } else { 71 - stats.wouldInbox += 1; 72 - } 73 - } 74 - 75 - if ((i + 1) % GMAIL_BATCH_SIZE === 0) { 76 - Utilities.sleep(100); 77 - } 78 - } 79 - 80 - const totalTime = ((Date.now() - startTime) / 1000).toFixed(2); 81 - Logger.log(`Summary: Inbox=${stats.wouldInbox}/${stats.didInbox}, Filtered=${stats.wouldFiltered}/${stats.didFiltered}, Errors=${stats.errors}, Time=${totalTime}s`); 82 - } 83 - 84 - function processThread( 85 - thread: GoogleAppsScript.Gmail.GmailThread, 86 - autoLabel: GoogleAppsScript.Gmail.GmailLabel, 87 - approvedLabel: GoogleAppsScript.Gmail.GmailLabel, 88 - filteredLabel: GoogleAppsScript.Gmail.GmailLabel, 89 - stats: any 90 - ): void { 91 - const msg = thread.getMessages()[thread.getMessages().length - 1]; 92 - if (!msg) throw new Error("No messages in thread"); 93 - 94 - const meta = { 95 - subject: safeStr(msg.getSubject()), 96 - body: safeStr(msg.getPlainBody(), 10000), 97 - from: safeStr(msg.getFrom()), 98 - }; 99 - 100 - if (!meta.subject && !meta.body) { 101 - Logger.log(`WARNING: No content. FAIL-SAFE: Moving to inbox.`); 102 - applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, "no content"); 103 - return; 104 - } 105 - 106 - const result = classifyEmail(meta); 107 - 108 - Logger.log(`[${thread.getId()}] Relevant=${result.pertains} Confidence=${result.confidence} Reason="${result.reason}"`); 109 - 110 - if (result.pertains) { 111 - applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, result.reason); 112 - } else { 113 - applyFilteredAction(thread, autoLabel, filteredLabel, stats, result.reason); 114 - } 115 - } 116 - 117 - function applyInboxAction( 118 - thread: GoogleAppsScript.Gmail.GmailThread, 119 - autoLabel: GoogleAppsScript.Gmail.GmailLabel, 120 - approvedLabel: GoogleAppsScript.Gmail.GmailLabel, 121 - filteredLabel: GoogleAppsScript.Gmail.GmailLabel, 122 - stats: any, 123 - reason: string 124 - ): void { 125 - if (DRY_RUN) { 126 - stats.wouldInbox += 1; 127 - Logger.log(` DRY_RUN: Would move to Inbox (${reason})`); 128 - } else { 129 - thread.removeLabel(autoLabel); 130 - thread.removeLabel(filteredLabel); 131 - thread.addLabel(approvedLabel); 132 - thread.moveToInbox(); 133 - stats.didInbox += 1; 134 - Logger.log(` Applied: Moved to Inbox (${reason})`); 135 - } 136 - } 137 - 138 - function applyFilteredAction( 139 - thread: GoogleAppsScript.Gmail.GmailThread, 140 - autoLabel: GoogleAppsScript.Gmail.GmailLabel, 141 - filteredLabel: GoogleAppsScript.Gmail.GmailLabel, 142 - stats: any, 143 - reason: string 144 - ): void { 145 - if (DRY_RUN) { 146 - stats.wouldFiltered += 1; 147 - Logger.log(` DRY_RUN: Would filter (${reason})`); 148 - } else { 149 - thread.removeLabel(autoLabel); 150 - thread.addLabel(filteredLabel); 151 - if (thread.isInInbox()) thread.moveToArchive(); 152 - stats.didFiltered += 1; 153 - Logger.log(` Applied: Filtered (${reason})`); 154 - } 155 - } 156 - 157 - // Classifier 158 - interface ClassificationResult { 159 - pertains: boolean; 160 - reason: string; 161 - confidence: number; 162 - } 163 - 164 - interface EmailMeta { 165 - subject: string; 166 - body: string; 167 - from: string; 168 - } 169 - 170 - function classifyEmail(meta: EmailMeta): ClassificationResult { 171 - const subject = meta.subject.toLowerCase(); 172 - const body = meta.body.toLowerCase(); 173 - const combined = subject + " " + body; 174 - 175 - // Security alerts - always relevant 176 - const securityResult = checkSecurity(combined); 177 - if (securityResult) return securityResult; 178 - 179 - // Student action confirmations 180 - const actionResult = checkStudentAction(combined); 181 - if (actionResult) return actionResult; 182 - 183 - // Accepted student info 184 - const acceptedResult = checkAccepted(combined); 185 - if (acceptedResult) return acceptedResult; 186 - 187 - // Dual enrollment 188 - const dualResult = checkDualEnrollment(combined); 189 - if (dualResult) return dualResult; 190 - 191 - // Scholarships 192 - const scholarshipResult = checkScholarship(subject, combined); 193 - if (scholarshipResult) return scholarshipResult; 194 - 195 - // Financial aid 196 - const aidResult = checkFinancialAid(combined); 197 - if (aidResult) return aidResult; 198 - 199 - // Marketing/spam 200 - const irrelevantResult = checkIrrelevant(combined); 201 - if (irrelevantResult) return irrelevantResult; 202 - 203 - // Default to not relevant 204 - return { pertains: false, reason: "No clear relevance indicators", confidence: 0.3 }; 205 - } 206 - 207 - function checkSecurity(combined: string): ClassificationResult | null { 208 - const patterns = [ 209 - /\bpassword\s+(reset|change|update|expired)\b/, 210 - /\breset\s+your\s+password\b/, 211 - /\baccount\s+security\b/, 212 - /\bsecurity\s+alert\b/, 213 - /\bunusual\s+(sign[- ]?in|activity)\b/, 214 - /\bverification\s+code\b/, 215 - /\b(2fa|mfa|two[- ]factor)\b/, 216 - /\bcompromised\s+account\b/, 217 - /\baccount\s+(locked|suspended)\b/, 218 - /\bsuspicious\s+activity\b/ 219 - ]; 220 - 221 - for (let i = 0; i < patterns.length; i++) { 222 - if (patterns[i].test(combined)) { 223 - if (/\bsaving.*\bon\s+tuition\b|\btuition.*\bsaving\b/.test(combined)) { 224 - continue; 225 - } 226 - return { pertains: true, reason: "Security/password alert", confidence: 1.0 }; 227 - } 228 - } 229 - return null; 230 - } 231 - 232 - function checkStudentAction(combined: string): ClassificationResult | null { 233 - const patterns = [ 234 - /\bapplication\s+(received|complete|submitted|confirmation)\b/, 235 - /\breceived\s+your\s+application\b/, 236 - /\bthank\s+you\s+for\s+(applying|submitting)\b/, 237 - /\benrollment\s+confirmation\b/, 238 - /\bconfirmation\s+(of|for)\s+(your\s+)?(application|enrollment)\b/, 239 - /\byour\s+application\s+(has\s+been|is)\s+(received|complete)\b/ 240 - ]; 241 - 242 - for (let i = 0; i < patterns.length; i++) { 243 - if (patterns[i].test(combined)) { 244 - if (/\bhow\s+to\s+apply\b|\bapply\s+now\b|\bstart\s+(your\s+)?application\b/.test(combined)) { 245 - continue; 246 - } 247 - return { pertains: true, reason: "Application/enrollment confirmation", confidence: 0.95 }; 248 - } 249 - } 250 - return null; 251 - } 252 - 253 - function checkAccepted(combined: string): ClassificationResult | null { 254 - const patterns = [ 255 - /\baccepted\s+(student\s+)?portal\b/, 256 - /\byour\s+(personalized\s+)?accepted\s+portal\b/, 257 - /\bdeposit\s+(today|now|by|to\s+reserve)\b/, 258 - /\breserve\s+your\s+(place|spot)\b/, 259 - /\bcongratulations.*\baccepted\b/, 260 - /\byou\s+(have\s+been|are|were)\s+accepted\b/, 261 - /\badmission\s+(decision|offer)\b/, 262 - /\benroll(ment)?\s+deposit\b/ 263 - ]; 264 - 265 - for (let i = 0; i < patterns.length; i++) { 266 - if (patterns[i].test(combined)) { 267 - if (/\bacceptance\s+rate\b|\bhigh\s+acceptance\b|\bpre[- ]admit(ted)?\b|\bautomatic\s+admission\b/.test(combined)) { 268 - continue; 269 - } 270 - if (/\byou\s+will\s+(also\s+)?receive\s+(an?\s+)?(accelerated\s+)?admission\s+decision\b/.test(combined)) { 271 - continue; 272 - } 273 - if (/\breceive\s+an\s+admission\s+decision\s+within\b/.test(combined)) { 274 - continue; 275 - } 276 - return { pertains: true, reason: "Accepted student information", confidence: 0.95 }; 277 - } 278 - } 279 - return null; 280 - } 281 - 282 - function checkDualEnrollment(combined: string): ClassificationResult | null { 283 - const patterns = [ 284 - /\bdual\s+enrollment\b/, 285 - /\bcourse\s+(registration|deletion|added|dropped)\b/, 286 - /\bspring\s+\d{4}\s+(course|on[- ]campus)\b/, 287 - /\bhow\s+to\s+register\b.*\b(course|class)/ 288 - ]; 289 - 290 - for (let i = 0; i < patterns.length; i++) { 291 - if (patterns[i].test(combined)) { 292 - if (/\blearn\s+more\s+about\b|\binterested\s+in\b|\bconsider\s+joining\b/.test(combined)) { 293 - continue; 294 - } 295 - return { pertains: true, reason: "Dual enrollment course information", confidence: 0.9 }; 296 - } 297 - } 298 - return null; 299 - } 300 - 301 - function checkScholarship(subject: string, combined: string): ClassificationResult | null { 302 - // Specific scholarship applications 303 - if (/\bapply\s+for\s+(the\s+)?.*\bscholarship\b/.test(subject)) { 304 - if (/\bpresident'?s\b|\bministry\b|\bimpact\b/.test(combined)) { 305 - return { pertains: true, reason: "Scholarship application opportunity", confidence: 0.75 }; 306 - } 307 - } 308 - 309 - // Not awarded patterns (check first) 310 - const notAwardedPatterns = [ 311 - /\bscholarship\b.*\b(held|reserved)\s+for\s+you\b/, 312 - /\b(held|reserved)\s+for\s+you\b/, 313 - /\bconsider(ed|ation)\b.*\bscholarship\b/, 314 - /\bscholarship\b.*\bconsider(ed|ation)\b/, 315 - /\beligible\s+for\b.*\bscholarship\b/, 316 - /\bscholarship\b.*\beligible\b/, 317 - /\bmay\s+qualify\b.*\bscholarship\b/ 318 - ]; 319 - 320 - if (/\bscholarship\b/.test(combined)) { 321 - for (let i = 0; i < notAwardedPatterns.length; i++) { 322 - if (notAwardedPatterns[i].test(combined)) { 323 - return { pertains: false, reason: "Scholarship not actually awarded", confidence: 0.9 }; 324 - } 325 - } 326 - } 327 - 328 - // Awarded patterns 329 - const awardedPatterns = [ 330 - /\bcongratulations\b.*\bscholarship\b/, 331 - /\byou\s+(have|received|are\s+awarded|won)\b.*\bscholarship\b/, 332 - /\bwe\s+(are\s+)?(pleased\s+to\s+)?award(ing)?\b.*\bscholarship\b/, 333 - /\bscholarship\s+(offer|award)\b/ 334 - ]; 335 - 336 - for (let i = 0; i < awardedPatterns.length; i++) { 337 - if (awardedPatterns[i].test(combined)) { 338 - return { pertains: true, reason: "Scholarship awarded", confidence: 0.95 }; 339 - } 340 - } 341 - 342 - return null; 343 - } 344 - 345 - function checkFinancialAid(combined: string): ClassificationResult | null { 346 - const readyPatterns = [ 347 - /\bfinancial\s+aid\b.*\boffer\b.*\b(ready|available)\b/, 348 - /\baward\s+letter\b.*\b(ready|available|posted|view)\b/, 349 - /\b(view|review)\s+(your\s+)?award\s+letter\b/, 350 - /\byour\s+aid\s+is\s+ready\b/ 351 - ]; 352 - 353 - const notReadyPatterns = [ 354 - /\blearn\s+more\s+about\b.*\bfinancial\s+aid\b/, 355 - /\bapply\b.*\b(for\s+)?financial\s+aid\b/, 356 - /\bcomplete\s+(your\s+)?fafsa\b/, 357 - /\bpriority\s+(deadline|consideration)\b.*\bfinancial\s+aid\b/ 358 - ]; 359 - 360 - for (let i = 0; i < readyPatterns.length; i++) { 361 - if (readyPatterns[i].test(combined)) { 362 - for (let j = 0; j < notReadyPatterns.length; j++) { 363 - if (notReadyPatterns[j].test(combined)) { 364 - return null; 365 - } 366 - } 367 - return { pertains: true, reason: "Financial aid offer ready", confidence: 0.95 }; 368 - } 369 - } 370 - return null; 371 - } 372 - 373 - function checkIrrelevant(combined: string): ClassificationResult | null { 374 - const patterns = [ 375 - /\bstudent\s+life\s+blog\b/, 376 - /\bnewsletter\b/, 377 - /\bweekly\s+(digest|update)\b/, 378 - /\bupcoming\s+events\b/, 379 - /\bjoin\s+us\s+(for|at)\b/, 380 - /\bopen\s+house\b/, 381 - /\bvirtual\s+tour\b/, 382 - /\bhaven'?t\s+applied.*yet\b/, 383 - /\bstill\s+time\s+to\s+apply\b/, 384 - /\bhow\s+is\s+your\s+college\s+search\b/, 385 - /\bextended.*\bpriority\s+deadline\b/, 386 - /\bpriority\s+deadline.*\bextended\b/, 387 - /\bsummer\s+(academy|camp|program)\b/, 388 - /\bugly\s+sweater\b/, 389 - /\bi\s+hope\s+you\s+have\s+been\s+receiving\s+my\s+emails\b/, 390 - /\bam\s+i\s+reaching\b/, 391 - /\byou\s+are\s+on\s+.*\s+(radar|list)\b/, 392 - /\bi\s+want\s+to\s+make\s+sure\s+you\s+know\b/, 393 - /\byou'?re\s+invited\s+to\s+submit\b/, 394 - /\bi'?m\s+eager\s+to\s+consider\s+you\b/, 395 - /\bsubmit\s+your\s+.*\s+application\b/, 396 - /\bpriority\s+status\b.*\bsubmit.*application\b/ 397 - ]; 398 - 399 - for (let i = 0; i < patterns.length; i++) { 400 - if (patterns[i].test(combined)) { 401 - return { pertains: false, reason: "Marketing/newsletter/spam", confidence: 0.95 }; 402 - } 403 - } 404 - 405 - if (/\bhaven'?t\s+applied\b/.test(combined)) { 406 - return { pertains: false, reason: "Unsolicited outreach", confidence: 0.95 }; 407 - } 408 - 409 - return null; 410 - } 411 - 412 - // Utilities 413 - function getOrCreateLabel(name: string): GoogleAppsScript.Gmail.GmailLabel { 414 - return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name); 415 - } 416 - 417 - function safeStr(s: string | null, maxLen?: number): string { 418 - if (s === null || s === undefined) return ""; 419 - const str = s.toString().trim(); 420 - if (maxLen && str.length > maxLen) return str.slice(0, maxLen); 421 - return str; 422 - } 423 - 424 - function setupTriggers(): void { 425 - // Delete existing triggers 426 - const triggers = ScriptApp.getProjectTriggers(); 427 - for (let i = 0; i < triggers.length; i++) { 428 - if (triggers[i].getHandlerFunction() === "runTriage") { 429 - ScriptApp.deleteTrigger(triggers[i]); 430 - } 431 - } 432 - 433 - // Create new trigger 434 - ScriptApp.newTrigger("runTriage") 435 - .timeBased() 436 - .everyMinutes(10) 437 - .create(); 438 - 439 - Logger.log("Trigger created: runTriage every 10 minutes"); 440 - } 441 - 1 + // DEPRECATED: This file is no longer used. 2 + // 3 + // The Apps Script is now built from wrapper.ts which imports the main classifier.ts 4 + // This ensures the Apps Script always uses the same logic as the TypeScript version. 5 + // 6 + // To build: bun run gas 7 + // Source: src/apps-script/wrapper.ts + src/classifier.ts 8 + // 9 + // This file is kept for reference only.
+196
src/apps-script/wrapper.ts
··· 1 + // Apps Script wrapper - imports the main classifier 2 + // This file is bundled into a single .gs file 3 + 4 + import { classifyEmail } from "../classifier"; 5 + import type { EmailInput, ClassificationResult } from "../types"; 6 + 7 + // Configuration 8 + const AUTO_LABEL_NAME = "College/Auto"; 9 + const FILTERED_LABEL_NAME = "College/Filtered"; 10 + const APPROVED_LABEL_NAME = "College"; 11 + const DRY_RUN = true; 12 + 13 + const MAX_THREADS_PER_RUN = 75; 14 + const MAX_EXECUTION_TIME_MS = 4.5 * 60 * 1000; 15 + const GMAIL_BATCH_SIZE = 20; 16 + 17 + // Declare global for Apps Script 18 + declare const GmailApp: any; 19 + declare const Logger: any; 20 + declare const Utilities: any; 21 + declare const ScriptApp: any; 22 + 23 + // Main entry points 24 + function ensureLabels(): void { 25 + getOrCreateLabel(AUTO_LABEL_NAME); 26 + getOrCreateLabel(FILTERED_LABEL_NAME); 27 + getOrCreateLabel(APPROVED_LABEL_NAME); 28 + Logger.log(`Labels ensured: ${AUTO_LABEL_NAME}, ${FILTERED_LABEL_NAME}, ${APPROVED_LABEL_NAME}`); 29 + } 30 + 31 + function runTriage(): void { 32 + const startTime = Date.now(); 33 + const autoLabel = getOrCreateLabel(AUTO_LABEL_NAME); 34 + const filteredLabel = getOrCreateLabel(FILTERED_LABEL_NAME); 35 + const approvedLabel = getOrCreateLabel(APPROVED_LABEL_NAME); 36 + 37 + const threads = autoLabel.getThreads(0, MAX_THREADS_PER_RUN); 38 + if (!threads.length) { 39 + Logger.log("No threads under College/Auto."); 40 + return; 41 + } 42 + 43 + Logger.log(`Processing ${threads.length} threads`); 44 + 45 + let stats = { 46 + wouldInbox: 0, 47 + wouldFiltered: 0, 48 + didInbox: 0, 49 + didFiltered: 0, 50 + errors: 0, 51 + skipped: 0 52 + }; 53 + 54 + for (let i = 0; i < threads.length; i++) { 55 + const elapsed = Date.now() - startTime; 56 + if (elapsed > MAX_EXECUTION_TIME_MS) { 57 + Logger.log(`Time limit reached. Processed ${i}/${threads.length}`); 58 + stats.skipped = threads.length - i; 59 + break; 60 + } 61 + 62 + const thread = threads[i]; 63 + 64 + try { 65 + processThread(thread, autoLabel, approvedLabel, filteredLabel, stats); 66 + } catch (e) { 67 + Logger.log(`ERROR: ${e}. FAIL-SAFE: Moving to inbox.`); 68 + stats.errors += 1; 69 + 70 + if (!DRY_RUN) { 71 + thread.removeLabel(autoLabel); 72 + thread.removeLabel(filteredLabel); 73 + thread.moveToInbox(); 74 + stats.didInbox += 1; 75 + } else { 76 + stats.wouldInbox += 1; 77 + } 78 + } 79 + 80 + if ((i + 1) % GMAIL_BATCH_SIZE === 0) { 81 + Utilities.sleep(100); 82 + } 83 + } 84 + 85 + const totalTime = ((Date.now() - startTime) / 1000).toFixed(2); 86 + Logger.log(`Summary: Inbox=${stats.wouldInbox}/${stats.didInbox}, Filtered=${stats.wouldFiltered}/${stats.didFiltered}, Errors=${stats.errors}, Time=${totalTime}s`); 87 + } 88 + 89 + function processThread( 90 + thread: any, 91 + autoLabel: any, 92 + approvedLabel: any, 93 + filteredLabel: any, 94 + stats: any 95 + ): void { 96 + const msg = thread.getMessages()[thread.getMessages().length - 1]; 97 + if (!msg) throw new Error("No messages in thread"); 98 + 99 + const meta: EmailInput = { 100 + subject: safeStr(msg.getSubject()), 101 + body: safeStr(msg.getPlainBody(), 10000), 102 + from: safeStr(msg.getFrom()), 103 + }; 104 + 105 + if (!meta.subject && !meta.body) { 106 + Logger.log(`WARNING: No content. FAIL-SAFE: Moving to inbox.`); 107 + applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, "no content"); 108 + return; 109 + } 110 + 111 + const result = classifyEmail(meta); 112 + 113 + Logger.log(`[${thread.getId()}] Relevant=${result.pertains} Confidence=${result.confidence} Reason="${result.reason}"`); 114 + 115 + if (result.pertains) { 116 + applyInboxAction(thread, autoLabel, approvedLabel, filteredLabel, stats, result.reason); 117 + } else { 118 + applyFilteredAction(thread, autoLabel, filteredLabel, stats, result.reason); 119 + } 120 + } 121 + 122 + function applyInboxAction( 123 + thread: any, 124 + autoLabel: any, 125 + approvedLabel: any, 126 + filteredLabel: any, 127 + stats: any, 128 + reason: string 129 + ): void { 130 + if (DRY_RUN) { 131 + stats.wouldInbox += 1; 132 + Logger.log(` DRY_RUN: Would move to Inbox (${reason})`); 133 + } else { 134 + thread.removeLabel(autoLabel); 135 + thread.removeLabel(filteredLabel); 136 + thread.addLabel(approvedLabel); 137 + thread.moveToInbox(); 138 + stats.didInbox += 1; 139 + Logger.log(` Applied: Moved to Inbox (${reason})`); 140 + } 141 + } 142 + 143 + function applyFilteredAction( 144 + thread: any, 145 + autoLabel: any, 146 + filteredLabel: any, 147 + stats: any, 148 + reason: string 149 + ): void { 150 + if (DRY_RUN) { 151 + stats.wouldFiltered += 1; 152 + Logger.log(` DRY_RUN: Would filter (${reason})`); 153 + } else { 154 + thread.removeLabel(autoLabel); 155 + thread.addLabel(filteredLabel); 156 + if (thread.isInInbox()) thread.moveToArchive(); 157 + stats.didFiltered += 1; 158 + Logger.log(` Applied: Filtered (${reason})`); 159 + } 160 + } 161 + 162 + // Utilities 163 + function getOrCreateLabel(name: string): any { 164 + return GmailApp.getUserLabelByName(name) || GmailApp.createLabel(name); 165 + } 166 + 167 + function safeStr(s: string | null, maxLen?: number): string { 168 + if (s === null || s === undefined) return ""; 169 + const str = s.toString().trim(); 170 + if (maxLen && str.length > maxLen) return str.slice(0, maxLen); 171 + return str; 172 + } 173 + 174 + function setupTriggers(): void { 175 + // Delete existing triggers 176 + const triggers = ScriptApp.getProjectTriggers(); 177 + for (let i = 0; i < triggers.length; i++) { 178 + if (triggers[i].getHandlerFunction() === "runTriage") { 179 + ScriptApp.deleteTrigger(triggers[i]); 180 + } 181 + } 182 + 183 + // Create new trigger 184 + ScriptApp.newTrigger("runTriage") 185 + .timeBased() 186 + .everyMinutes(10) 187 + .create(); 188 + 189 + Logger.log("Trigger created: runTriage every 10 minutes"); 190 + } 191 + 192 + // Export for Apps Script global scope - these become top-level functions 193 + // Note: The bundler needs to be configured to expose these properly 194 + 195 + // Make sure functions are not tree-shaken by referencing them 196 + export { ensureLabels, runTriage, setupTriggers };
+28 -15
src/build-gas.ts
··· 1 1 #!/usr/bin/env bun 2 2 // Build script for Google Apps Script 3 3 4 - import { execSync } from "child_process"; 4 + import * as esbuild from "esbuild"; 5 + import { readFileSync, writeFileSync, mkdirSync } from "fs"; 5 6 6 7 const command = process.argv[2] || "build"; 7 8 8 9 if (command === "build") { 9 - console.log("🔨 Building for Apps Script (local .gs file)...\n"); 10 + console.log("🔨 Building for Apps Script (bundled from main classifier)...\n"); 10 11 11 - // Compile TypeScript 12 - console.log("1️⃣ Compiling TypeScript..."); 12 + // Bundle with esbuild 13 + console.log("1️⃣ Bundling with esbuild..."); 13 14 try { 14 - execSync("tsc -p tsconfig.apps-script.json", { stdio: "inherit" }); 15 - console.log("✅ TypeScript compiled\n"); 15 + await esbuild.build({ 16 + entryPoints: ["src/apps-script/wrapper.ts"], 17 + bundle: true, 18 + outfile: "build/bundled.js", 19 + format: "esm", // Use ES modules format 20 + target: "es2015", 21 + platform: "neutral", 22 + }); 23 + console.log("✅ Bundled successfully\n"); 16 24 } catch (e) { 17 - console.error("❌ TypeScript compilation failed"); 25 + console.error("❌ Bundling failed:", e); 18 26 process.exit(1); 19 27 } 20 28 21 - // Create .gs file 22 - console.log("2️⃣ Creating .gs file..."); 23 - const { mkdirSync, readFileSync, writeFileSync } = await import("fs"); 29 + // Post-process: Convert to Apps Script compatible format 30 + console.log("2️⃣ Post-processing for Apps Script..."); 24 31 mkdirSync("build", { recursive: true }); 25 - const js = readFileSync("build/compiled/Code.js", "utf-8"); 32 + let js = readFileSync("build/bundled.js", "utf-8"); 33 + 34 + // Remove export statements - functions will be top-level 35 + js = js.replace(/^export \{[^}]+\};?\s*$/gm, ''); 36 + js = js.replace(/^export (function|const|var|let) /gm, '$1 '); 26 37 27 - const header = `// Auto-generated from TypeScript at: ${new Date().toISOString()} 28 - // Source: src/apps-script/Code.ts 38 + const output = `// Auto-generated from TypeScript at: ${new Date().toISOString()} 39 + // Source: src/apps-script/wrapper.ts + src/classifier.ts 29 40 // DO NOT EDIT THIS FILE DIRECTLY - Edit the TypeScript source instead 41 + // This file is bundled from the main classifier to ensure consistency 30 42 43 + ${js} 31 44 `; 32 45 33 - writeFileSync("build/Code.gs", header + js); 46 + writeFileSync("build/Code.gs", output); 34 47 console.log("✅ Created build/Code.gs\n"); 35 48 36 49 console.log("🎉 Build complete!"); ··· 43 56 } else { 44 57 console.log("Usage: bun run gas [build]"); 45 58 console.log("\nCommands:"); 46 - console.log(" build - Compile TypeScript to .gs file (default)"); 59 + console.log(" build - Bundle TypeScript to .gs file (default)"); 47 60 }
+18 -1
src/classifier.ts
··· 132 132 if (/\breceive\s+an\s+admission\s+decision\s+within\b/.test(combined)) { 133 133 return null; 134 134 } 135 + // Exclude "Priority Student" spam that asks to submit application 136 + if (/\bpriority\s+student\b.*\bsubmit.*application\b|\bsubmit.*\bpriority\s+student\s+application\b/.test(combined)) { 137 + return null; 138 + } 139 + // Exclude if asking to submit ANY application (not accepted yet) 140 + if (/\bif\s+you\s+haven'?t\s+yet\s+done\s+so.*submit\b|\bsubmit\s+the\b.*\bapplication\b/.test(combined)) { 141 + return null; 142 + } 143 + // Exclude "reserve your spot" for events/webinars (not enrollment) 144 + if (/\breserve\s+your\s+spot\b/.test(combined) && /\b(virtual|webinar|event|program|zoom|session)\b/.test(combined)) { 145 + return null; 146 + } 135 147 return { 136 148 pertains: true, 137 149 reason: "Accepted student portal/deposit information", ··· 291 303 292 304 // Marketing events 293 305 /\bupcoming\s+events\b/, 294 - /\bjoin\s+us\s+(for|at)\b/, 306 + /\bjoin\s+us\s+(for|at|on\s+zoom)\b/, 295 307 /\bopen\s+house\b/, 296 308 /\bvirtual\s+tour\b/, 297 309 /\bcampus\s+(visit|tour|event)\b/, ··· 325 337 // Ugly sweaters and other fluff 326 338 /\bugly\s+sweater\b/, 327 339 /\bit'?s\s+.+\s+season\b/, 340 + 341 + // FAFSA/scholarship info sessions (not actual aid offers) 342 + /\bjoin\s+us.*\b(virtual\s+program|zoom)\b.*\b(scholarship|financial\s+aid)\b/, 343 + /\blearn\s+more\b.*\b(scholarship|financial\s+aid)\s+(opportunities|options)\b/, 344 + /\b(scholarship|financial\s+aid)\s+(opportunities|options)\b.*\blearn\s+more\b/, 328 345 ]; 329 346 330 347 for (const pattern of irrelevantPatterns) {