diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32bd65e..cdf71d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ jobs: GOOGLE_USER_EMAIL: ${{ secrets.GOOGLE_USER_EMAIL }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} steps: - name: Checkout the repository diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f9bb6a..e1a04f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,6 @@ jobs: GOOGLE_USER_EMAIL: ${{ secrets.GOOGLE_USER_EMAIL }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} - GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} GOOGLE_REFRESH_TOKEN: ${{ secrets.GOOGLE_REFRESH_TOKEN }} steps: diff --git a/CLAUDE.md b/CLAUDE.md index 7df72ae..227e380 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,6 @@ All source code lives in the `/app` directory. Run all npm commands from `/app`. GOOGLE_USER_EMAIL GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET -GOOGLE_REDIRECT_URI # https://developers.google.com/oauthplayground GOOGLE_REFRESH_TOKEN ``` @@ -63,27 +62,26 @@ EmailTransport (lib/email/transport.ts) └── EmailSender (lib/email/sender.ts) ``` -- **`GmailOAuthClient`** (`lib/google/oauth2client.ts`) — Manages Google OAuth2 credentials; validates env vars via Zod schema on instantiation. Accepts optional constructor params; falls back to env vars. -- **`EmailTransport`** — Base class; initializes Nodemailer transporter via `createTransport3LO()`, which fetches a fresh access token from `GmailOAuthClient` if one hasn't been pre-generated. +- **`EmailTransport`** — Base class; initializes Nodemailer transporter via `createTransport3LO(options?)`. Reads Google OAuth2 credentials from `options` fields, falling back to `GOOGLE_*` env vars. Nodemailer handles token refresh internally via the `refreshToken` field. - **`EmailSender`** — Extends `EmailTransport`; exposes `sendEmail()` method; validates email params with Zod schema. Supports a single `recipient` string or a `recipients[]` array (max 20 total). - **`SchemaValidator`** (`lib/validator/schemavalidator.ts`) — Generic Zod validation wrapper; handles both `ZodObject` and `ZodEffects` (`.refine()`) schemas; supports partial validation via `pick`. ### Key Data Flow -1. **Library usage**: `send()` (`lib/email/send.ts`) → creates `GmailOAuthClient` + `EmailSender` → `createTransport3LO()` → `sendEmail()` +1. **Library usage**: `send(params, oauth2?)` (`lib/email/send.ts`) → creates `EmailSender` → `createTransport3LO(oauth2?)` → `sendEmail()` 2. **CLI text**: Commander.js (`scripts/cli/send.ts`) → `handleSendTextEmail` → `send()` 3. **CLI HTML**: Commander.js → `handleSendHtmlEmail` → `buildHtml()` (renders EJS template, sanitizes HTML) → `send()` with `isHtml: true` 4. **SEA builds**: `build.ts` checks `IS_BUILD_SEA=true` to import the EJS template via `import()` (baked into the binary) rather than reading it from disk. ### Public API (`src/index.ts`) -Exports: `send`, `buildHtml`, `EmailSender`, `EmailTransport`, `GmailOAuthClient`, `SchemaValidator`, plus all types from `src/types/`. +Exports: `send`, `buildHtml`, `EmailSender`, `EmailTransport`, `SchemaValidator`, plus all types from `src/types/`. ### Validation All input validation uses Zod schemas in `src/types/`: - `email.schema.ts` — `EmailSchema` for `send()` params; `HtmlBuildSchema` for `buildHtml()` params; `EmailTextOptions` / `EmailHtmlOptions` interfaces for CLI handlers -- `oauth2client.schema.ts` — Google OAuth2 env vars +- `transport.schema.ts` — `TransportOath2Schema` / `TransportOath2SchemaType` for optional OAuth2 credentials passed to `send()` and `createTransport3LO()` ### ESM Compatibility diff --git a/README.md b/README.md index 3ac4179..fb01d51 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,6 @@ We welcome contributions! Please see [CONTRIBUTING.md](/CONTRIBUTING.md) and the | GOOGLE_USER_EMAIL | Your Google email that you've configured for Gmail SMTP and Google OAuth2. | | GOOGLE_CLIENT_ID | Google OAuth2 client ID linked with your Google Cloud Platform project. | | GOOGLE_CLIENT_SECRET | Google OAuth2 client secret associated with the `GOOGLE_CLIENT_ID`. | - | GOOGLE_REDIRECT_URI | Allowed Google API redirect URI. Its value is `https://developers.google.com/oauthplayground` by default. | | GOOGLE_REFRESH_TOKEN | The initial (or any) refresh token obtained from the [OAuthPlayground](https://developers.google.com/oauthplayground). | diff --git a/app/.env.example b/app/.env.example index da024e0..52eb6a3 100644 --- a/app/.env.example +++ b/app/.env.example @@ -1,5 +1,4 @@ GOOGLE_USER_EMAIL=YOUR_GMAIL_OAUTH_EMAIL_HERE GOOGLE_CLIENT_ID=YOUR_GOOGLE_PROJECT_CLIENT_ID_HERE GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_PROJECT_CLIENT_SECRET_HERE -GOOGLE_REDIRECT_URI=https://developers.google.com/oauthplayground GOOGLE_REFRESH_TOKEN=ANY_REFRESH_TOKEN_GENERATED_FROM_OAUTHPLAYGROUND_HERE \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 4ceb334..d94c363 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,18 +1,17 @@ { "name": "@weaponsforge/sendemail", - "version": "1.2.7", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@weaponsforge/sendemail", - "version": "1.2.7", + "version": "2.0.0", "license": "MIT", "dependencies": { "commander": "^14.0.3", "dotenv": "^17.4.2", "ejs": "^5.0.2", - "googleapis": "^171.4.0", "nodemailer": "^8.0.5", "sanitize-html": "^2.17.3", "zod": "^3.24.2" @@ -1061,6 +1060,29 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.15", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", @@ -1639,15 +1661,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1721,35 +1734,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1789,41 +1773,6 @@ "node": ">=8" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1890,19 +1839,11 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2034,29 +1975,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ejs": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", @@ -2081,24 +1999,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2106,18 +2006,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", @@ -2386,12 +2274,6 @@ "node": ">=12.0.0" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2440,29 +2322,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -2550,18 +2409,6 @@ "dev": true, "license": "ISC" }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2577,80 +2424,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -2728,73 +2501,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/googleapis": { - "version": "171.4.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", - "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.2.0", - "googleapis-common": "^8.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/googleapis-common": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", - "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "gaxios": "^7.0.0-rc.4", - "google-auth-library": "^10.1.0", - "qs": "^6.7.0", - "url-template": "^2.0.8" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2805,30 +2511,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2855,19 +2537,6 @@ "entities": "^7.0.1" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -3045,15 +2714,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3075,27 +2735,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3435,15 +3074,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3498,6 +3128,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mylas": { @@ -3539,44 +3170,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/nodemailer": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", @@ -3626,18 +3219,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3877,21 +3458,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", @@ -4015,26 +3581,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/sanitize-html": { "version": "2.17.3", "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", @@ -4085,78 +3631,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4989,12 +4463,6 @@ "punycode": "^2.1.0" } }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "license": "BSD" - }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", @@ -5191,15 +4659,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/app/package.json b/app/package.json index 8b553c5..f1a5cca 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@weaponsforge/sendemail", - "version": "1.2.7", + "version": "2.0.0", "description": "Sends emails using Gmail SMTP with username/pw or Google OAuth2", "main": "dist/index.js", "types": "./dist/index.d.ts", @@ -68,7 +68,6 @@ "commander": "^14.0.3", "dotenv": "^17.4.2", "ejs": "^5.0.2", - "googleapis": "^171.4.0", "nodemailer": "^8.0.5", "sanitize-html": "^2.17.3", "zod": "^3.24.2" diff --git a/app/src/__tests__/oauth2client.test.ts b/app/src/__tests__/oauth2client.test.ts deleted file mode 100644 index 5a7316a..0000000 --- a/app/src/__tests__/oauth2client.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { z } from 'zod' -import { describe, expect, it } from 'vitest' -import { GmailOAuthClient } from '@/lib/index.js' -import type { GetAccessTokenResponse } from '@/types/oauth2client.types.js' - -describe('Google OAuth2 Client class test', () => { - it('should generate an access token', async () => { - const oauthClient = new GmailOAuthClient() - const token = await oauthClient.getAccessToken() - - expect(token).toHaveProperty('token') - expect(token).toHaveProperty('res') - }) - - it('should generate and store a new access token', async () => { - const oauthClient = new GmailOAuthClient() - await oauthClient.generateAccessToken() - - expect(oauthClient.accessToken).toHaveProperty('token') - expect(oauthClient.accessToken).toHaveProperty('res') - }) - - it ('should generate an access token using class constructor parameters', async () => { - const oauthClient = new GmailOAuthClient({ - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - redirectURI: process.env.GOOGLE_REDIRECT_URI, - refreshToken: process.env.GOOGLE_REFRESH_TOKEN, - userEmail: process.env.GOOGLE_USER_EMAIL, - }) - - const token = await oauthClient.getAccessToken() - - expect(token).toHaveProperty('token') - expect(token).toHaveProperty('res') - }) - - it ('should throw an error if incorrect schema is provided', async () => { - // See @/types/oauth2client.schema.ts for the correct schema - const wrongSchema = z.object({ - hello: z.string(), - world: z.number(), - }) - - expect(() => new GmailOAuthClient(null, wrongSchema)).toThrow() - }) - - it ('should throw an error when manually setting an incorrect access token', async () => { - const oauthClient = new GmailOAuthClient() - - const accessToken = { - token: 123, // Expected to be a string - res: 'hello', // Expected to be an object - } as unknown as GetAccessTokenResponse - - expect(() => - oauthClient.accessToken = accessToken, - ).toThrow() - }) -}) diff --git a/app/src/__tests__/send.test.ts b/app/src/__tests__/send.test.ts index 17b67b4..780502f 100644 --- a/app/src/__tests__/send.test.ts +++ b/app/src/__tests__/send.test.ts @@ -1,20 +1,29 @@ -import { beforeAll, describe, expect, test } from 'vitest' +import { describe, expect, test } from 'vitest' import { send } from '@/lib/index.js' -import { GmailOAuthClient } from '@/lib/index.js' const MAX_TIMEOUT = 10000 const TEST_RECIPIENT = 'tester@gmail.com' describe('Send email test', () => { - const oauthClient = new GmailOAuthClient() + test('Accept OAuth2 settings from parameters', async () => { + const auth = { + googleUserEmail: process.env.GOOGLE_USER_EMAIL || '', + googleClientId: process.env.GOOGLE_CLIENT_ID || '', + googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '', + googleRereshToken: process.env.GOOGLE_REFRESH_TOKEN || '', + } - // Generates an access token to use for the succeeding tests - beforeAll(async () => { - await oauthClient.generateAccessToken() - }) + await expect( + send({ + recipient: TEST_RECIPIENT, + subject: 'Test Simple Message', + content: 'Henlo!', + }, auth), + ).resolves.toBeUndefined() + }, MAX_TIMEOUT) + // Ensure .env file contains the required GOOGLE_* variables before running tests below test('Send a text email', async () => { - // Sending email this way auto-generates a fresh access token await expect(send({ recipient: TEST_RECIPIENT, subject: 'Test Simple Message', @@ -22,42 +31,24 @@ describe('Send email test', () => { })).resolves.toBeUndefined() }, MAX_TIMEOUT) - test('Send a text email using pre-generated OAuth2 access token', async () => { - // Sending email this way does not regenerate an access token - await expect( - send( - { - recipient: TEST_RECIPIENT, - subject: 'Test Simple Message', - content: 'Henlo!', - }, oauthClient, - ), - ).resolves.toBeUndefined() - }, MAX_TIMEOUT) - test('Send a text email to multiple recipients[]', async () => { await expect( - send( - { - recipients: ['student1@gmail.com', 'student2@gmail.com'], - subject: 'Test Multiple Message', - content: 'Henlo, hello!', - }, oauthClient, - ), + send({ + recipients: ['student1@gmail.com', 'student2@gmail.com'], + subject: 'Test Multiple Message', + content: 'Henlo, hello!', + }), ).resolves.toBeUndefined() }, MAX_TIMEOUT) - test('should send email to both recipient and recipients[]', async () => { + test('should send email to recipient and recipients[]', async () => { await expect( - send( - { - recipients: ['person1@gmail.com', 'person2@gmail.com'], - recipient: TEST_RECIPIENT, - subject: 'Test Multiple Message 2', - content: 'Hello there', - }, - oauthClient, - ), + send({ + recipients: ['person1@gmail.com', 'person2@gmail.com'], + recipient: TEST_RECIPIENT, + subject: 'Test Multiple Message 2', + content: 'Hello there', + }), ).resolves.toBeUndefined() }, MAX_TIMEOUT) }) diff --git a/app/src/__tests__/sendFormat.test.ts b/app/src/__tests__/sendFormat.test.ts index 2065c38..1f96e6f 100644 --- a/app/src/__tests__/sendFormat.test.ts +++ b/app/src/__tests__/sendFormat.test.ts @@ -1,6 +1,6 @@ -import { beforeAll, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' -import { GmailOAuthClient, send } from '@/lib/index.js' +import { send } from '@/lib/index.js' import { createLongString, createRandomTextArray } from '@/utils/helpers.js' import { EmailSchemaMessages } from '@/types/email.schema.js' @@ -10,103 +10,72 @@ const TEST_RECIPIENT = 'tester@gmail.com' const TEST_SUBJECT = 'Test Simple Message' describe('Email format test', () => { - const oauthClient = new GmailOAuthClient() - - // Generates an access token to use for the succeeding tests - beforeAll(async () => { - await oauthClient.generateAccessToken() - }) - - // Testing email formats it('should reject invalid email address format', async () => { const invalidEmail = 'tester!#5@.4/' await expect( - send( - { - recipient: invalidEmail, - subject: TEST_SUBJECT, - content: 'Henlo!', - }, - oauthClient, - ), + send({ + recipient: invalidEmail, + subject: TEST_SUBJECT, + content: 'Henlo!', + }), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL) }, MAX_TIMEOUT) - // Testing long email addresses it('should reject email addresses longer than 150 characters', async () => { const longRecipientEmail = createLongString(150) + '@gmail.com' await expect( - send( - { - recipient: longRecipientEmail, - subject: TEST_SUBJECT, - content: 'Henlo!', - }, - oauthClient, - ), + send({ + recipient: longRecipientEmail, + subject: TEST_SUBJECT, + content: 'Henlo!', + }), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH) }, MAX_TIMEOUT) - // Testing long subject/title content it('should reject subject/title longer that 200 characters', async () => { const longTitle = createLongString(201) await expect( - send( - { - recipient: TEST_RECIPIENT, - subject: longTitle, - content: 'Henlo!', - }, - oauthClient, - ), + send({ + recipient: TEST_RECIPIENT, + subject: longTitle, + content: 'Henlo!', + }), ).rejects.toThrow(EmailSchemaMessages.SUBJECT) }, MAX_TIMEOUT) - // Testing long email message content it('should reject email message content longer than 1500 characters', async () => { const longContent = createLongString(1501) await expect( - send( - { - recipient: TEST_RECIPIENT, - subject: TEST_SUBJECT, - content: longContent, - }, - oauthClient, - ), + send({ + recipient: TEST_RECIPIENT, + subject: TEST_SUBJECT, + content: longContent, + }), ).rejects.toThrow(EmailSchemaMessages.CONTENT) }, MAX_TIMEOUT) - // Testing missing recipient AND recipients[] it('should reject if recipient and recipients[] are missing', async () => { await expect( - send( - { - subject: TEST_SUBJECT, - content: 'Hello there', - }, - oauthClient, - ), + send({ + subject: TEST_SUBJECT, + content: 'Hello there', + }), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_REQUIRED) }, MAX_TIMEOUT) - // Test maximum 20 recipients it('should reject if recipients[] are more than 20', async () => { const emailList = createRandomTextArray({ length: 21, suffix: '@gmail.com' }) await expect( - send( - { - recipients: emailList, - subject: TEST_SUBJECT, - content: 'Hello there', - }, - oauthClient, - ), + send({ + recipients: emailList, + subject: TEST_SUBJECT, + content: 'Hello there', + }), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_MAX) }, MAX_TIMEOUT) @@ -114,15 +83,12 @@ describe('Email format test', () => { const emailList = createRandomTextArray({ length: 20, suffix: '@gmail.com' }) await expect( - send( - { - recipients: emailList, - recipient: TEST_RECIPIENT, - subject: TEST_SUBJECT, - content: 'Hello there', - }, - oauthClient, - ), + send({ + recipients: emailList, + recipient: TEST_RECIPIENT, + subject: TEST_SUBJECT, + content: 'Hello there', + }), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_MAX) }, MAX_TIMEOUT) }) diff --git a/app/src/index.ts b/app/src/index.ts index de0bd2d..942ce8e 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -1,7 +1,6 @@ export { EmailSender, EmailTransport, - GmailOAuthClient, SchemaValidator, buildHtml, send, diff --git a/app/src/lib/email/send.ts b/app/src/lib/email/send.ts index 10ee793..53809c2 100644 --- a/app/src/lib/email/send.ts +++ b/app/src/lib/email/send.ts @@ -1,15 +1,18 @@ import EmailSender from '@/lib/email/sender.js' -import GmailOAuthClient from '@/lib/google/oauth2client.js' import { TRANSPORT_AUTH_TYPES, TRANSPORT_SMTP_HOSTS } from '@/types/transport.types.js' import type { EmailType } from '@/types/email.schema.js' +import type { TransportOath2SchemaType } from '@/types/transport.schema.js' /** * Sends an email to a recipient (text or HTML) * @param {EmailType} params Email sending input parameters + * @param {oauth2} options Optional Google OAuth2 settings. Uses the `GOOGLE_*` env variables by default. */ -export const send = async (params: EmailType, client?: GmailOAuthClient): Promise => { - const oauthClient = client || new GmailOAuthClient() +export const send = async ( + params: EmailType, + oauth2?: TransportOath2SchemaType, +): Promise => { const { recipient, recipients, subject, content, isHtml = false } = params const handler = new EmailSender({ @@ -18,7 +21,7 @@ export const send = async (params: EmailType, client?: GmailOAuthClient): Promis }) try { - await handler.createTransport3LO(oauthClient) + await handler.createTransport3LO(oauth2) } catch (err: unknown) { if (err instanceof Error) { throw err diff --git a/app/src/lib/email/transport.ts b/app/src/lib/email/transport.ts index 956a6ca..25b7e46 100644 --- a/app/src/lib/email/transport.ts +++ b/app/src/lib/email/transport.ts @@ -1,6 +1,7 @@ import nodemailer from 'nodemailer' -import GmailOAuthClient from '@/lib/google/oauth2client.js' +import SchemaValidator from '@/lib/validator/schemavalidator.js' +import { TransportOath2Schema, type TransportOath2SchemaType } from '@/types/transport.schema.js' import { TRANSPORT_SMTP_HOSTS, TRANSPORT_AUTH_TYPES } from '@/types/transport.types.js' import type { IEmailTransportAuth, SMTPTransport } from '@/types/transport.types.js' import type { IEmailTransport } from '@/types/transport.interface.js' @@ -11,6 +12,8 @@ import type { IEmailTransport } from '@/types/transport.interface.js' * and initiates sending emails using the Gmail SMTP */ class EmailTransport implements IEmailTransport { + /** Zod schema wrapper methods and functions */ + #schema: SchemaValidator | null = null /** Nodemailer tansport */ #transporter: nodemailer.Transporter | null = null @@ -27,29 +30,30 @@ class EmailTransport implements IEmailTransport { constructor (params?: IEmailTransportAuth) { this.#host = params?.host || TRANSPORT_SMTP_HOSTS.GMAIL this.#type = params?.type || TRANSPORT_AUTH_TYPES.OAUTH2 + this.#schema = new SchemaValidator(TransportOath2Schema) } - async createTransport3LO (oauth2Client: GmailOAuthClient): Promise { + async createTransport3LO (options?: TransportOath2SchemaType): Promise { try { - let token = oauth2Client.accessToken - - // Generate and retrieve a fresh access token - if (!token) { - token = await oauth2Client.getAccessToken() + const inputData = { + googleUserEmail: options?.googleUserEmail || process.env.GOOGLE_USER_EMAIL, + googleClientId: options?.googleClientId || process.env.GOOGLE_CLIENT_ID, + googleClientSecret: options?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET, + googleRereshToken: options?.googleRereshToken || process.env.GOOGLE_REFRESH_TOKEN, } - // Initialize the nodemailer transport with a fresh access token + this.#schema?.validate({ data: inputData }) + this.#transporter = nodemailer.createTransport({ host: this.#host, port: 465, secure: true, auth: { type: this.#type, - user: oauth2Client.email, - clientId: oauth2Client.client?._clientId, - clientSecret: oauth2Client.client?._clientSecret, - refreshToken: oauth2Client?.refreshToken, - accessToken: token, + user: inputData.googleUserEmail, + clientId: inputData.googleClientId, + clientSecret: inputData.googleClientSecret, + refreshToken: inputData.googleRereshToken, }, }) } catch (err: unknown) { diff --git a/app/src/lib/google/oauth2client.ts b/app/src/lib/google/oauth2client.ts deleted file mode 100644 index 9e7deee..0000000 --- a/app/src/lib/google/oauth2client.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { google } from 'googleapis' -import dotenv from 'dotenv' -dotenv.config({ quiet: true }) - -import SchemaValidator from '@/lib/validator/schemavalidator.js' -import type { IGmailOAuthClient } from '@/types/oauth2client.interface.js' -import type { ZodObjectBasicType } from '@/types/schemavalidator.interface.js' -import { GmailOAuthClientSchema } from '@/types/oauth2client.schema.js' - -import { - type GetAccessTokenResponse, - type IOauthClient, - type OAuth2Client, -} from '@/types/oauth2client.types.js' - -/** - * @class GmailOAuthClient - * @description Manages relevant API keys, methods and properties of the Google `OAuth2Client` - */ -class GmailOAuthClient implements IGmailOAuthClient { - /** Local instance of the `OAuth2Client` */ - #client: OAuth2Client | null = null - - /** Email sender associated with the API keys of the `OAuth2Client` */ - #email: string | null = null - - /** Google OAuth2 refresh token from the Google OAuth2 Playground */ - #refreshToken: string | null = null - - /** Google OAuth2 access token generated using the #refreshToken */ - #accessToken: GetAccessTokenResponse | null = null - - /** Zod schema wrapper methods and functions */ - #schema: SchemaValidator | null = null - - /** - * @constructor - * @param {Partial} params (Optional) constructor parameters. The corresponding `.env` variables - * expect to have correct values for the default values. - * @param {ZodObjectBasicType} [schema] (Optional) zod Schema. Defaults to the `ZodSchemaTypes` if not provided. - */ - constructor (params?: Partial | null, schema?: ZodObjectBasicType) { - const clientId = params?.clientId || process.env.GOOGLE_CLIENT_ID - const clientSecret = params?.clientSecret || process.env.GOOGLE_CLIENT_SECRET - const redirectURI = params?.redirectURI || process.env.GOOGLE_REDIRECT_URI - const refreshToken = params?.refreshToken || process.env.GOOGLE_REFRESH_TOKEN - const userEmail = params?.userEmail || process.env.GOOGLE_USER_EMAIL - - this.#schema = new SchemaValidator(schema || GmailOAuthClientSchema) - - this.init({ - clientId, - clientSecret, - redirectURI, - refreshToken, - userEmail, - }) - } - - init (params: IOauthClient): void { - try { - this.#schema?.validate({ data: { ...params } }) - - const { clientId, clientSecret, redirectURI, refreshToken, userEmail } = params - - this.#client = new google.auth.OAuth2( - clientId, - clientSecret, - redirectURI, - ) - - this.#client.setCredentials({ refresh_token: refreshToken }) - - this.#email = userEmail - this.#refreshToken = refreshToken - } catch (err: unknown) { - if (err instanceof Error) { - throw err - } else { - throw new Error(`Unexpected error type: ${String(err)}`, { cause: err }) - } - } - } - - async getAccessToken (): Promise { - this.checkClient() - - return await this.#client!.getAccessToken() - } - - async generateAccessToken (): Promise { - this.checkClient() - this.#accessToken = await this.getAccessToken() - } - - checkClient (): void { - if (!this.#client) { - throw new Error('Undefined OAuth2 client') - } - } - - set accessToken (accessToken: GetAccessTokenResponse | null) { - this.#schema?.checkSchema() - - this.#schema?.validate({ - data: { accessToken }, - pick: true, - }) - - this.#accessToken = accessToken - } - - get schema (): SchemaValidator | null { - return this.#schema - } - - get client (): OAuth2Client | null { - return this.#client - } - - get email (): string | null { - return this.#email - } - - get refreshToken (): string | null { - return this.#refreshToken - } - - get accessToken (): GetAccessTokenResponse | null { - return this.#accessToken - } -} - -export default GmailOAuthClient diff --git a/app/src/lib/index.ts b/app/src/lib/index.ts index fa06f26..a89a795 100644 --- a/app/src/lib/index.ts +++ b/app/src/lib/index.ts @@ -1,6 +1,5 @@ import EmailTransport from '@/lib/email/transport.js' import EmailSender from '@/lib/email/sender.js' -import GmailOAuthClient from '@/lib/google/oauth2client.js' import SchemaValidator from '@/lib/validator/schemavalidator.js' export { send } from '@/lib/email/send.js' @@ -9,6 +8,5 @@ export { buildHtml } from '@/lib/email/build.js' export { EmailSender, EmailTransport, - GmailOAuthClient, SchemaValidator, } diff --git a/app/src/types/oauth2client.interface.ts b/app/src/types/oauth2client.interface.ts deleted file mode 100644 index c887fd9..0000000 --- a/app/src/types/oauth2client.interface.ts +++ /dev/null @@ -1,76 +0,0 @@ -import SchemaValidator from '@/lib/validator/schemavalidator.js' - -import type { - GetAccessTokenResponse, - IOauthClient, - OAuth2Client, -} from '@/types/oauth2client.types.js' - -/** - * Public properties and methods types of the `GmailOAuthClient` class - * @interface IGmailOAuthClient - */ -export interface IGmailOAuthClient { - /** - * Initializes the Google OAuth2 local client. - * Keeps track of the client email and initial refresh token from the `.env` file. - * @param {IOauthClient} params Parsed and formatted constructor parameters - * @returns {void} - */ - init (params: IOauthClient): void; - - /** - * Generates a new Google OAuth2 access token using the refresh token - * @returns {Promise} Promise that resolves to the `GetAccessTokenResponse` Google OAuth2 access token - */ - getAccessToken (): Promise; - - /** - * Generates a Google OAuth2 access token, storing it in the local `this.#accessToken` - * @returns {Promise} - */ - generateAccessToken (): Promise; - - /** - * Validates that the OAuth2 client is initialized - * @throws {Error} When the OAuth2 client is null - */ - checkClient (): void; - - /** - * Sets the value of the local access token - */ - set accessToken (accessToken: GetAccessTokenResponse | null); - - /** - * Retrieves the local zod schema wrapper - * @returns {SchemaValidator | null} Local zod schema validator and wrapper - */ - get schema (): SchemaValidator | null; - - /** - * Retrieves the local-initialized Google OAuth2 client - * @returns {OAuth2Client | null} local Google OAuth2 client - */ - get client (): OAuth2Client | null; - - /** - * Retrieves the email associated with the Google OAuth2 client - * @returns {string | null} Google OAuth2 client email - */ - get email (): string | null; - - /** - * Retrieves the refresh token originally generated from the Google OAuth2 Playground at - * https://developers.google.com/oauthplayground - * @returns {string | null} Google OAuth2 refresh token - */ - get refreshToken (): string | null; - - /** - * Retrieves the local access token if initialized - * @returns {GetAccessTokenResponse | null} Google Oauth2 access token - */ - get accessToken (): GetAccessTokenResponse | null; -} - diff --git a/app/src/types/oauth2client.schema.ts b/app/src/types/oauth2client.schema.ts deleted file mode 100644 index 8a117cc..0000000 --- a/app/src/types/oauth2client.schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { z } from 'zod' - -export const GmailOAuthAccessTokenSchema = z.object({ - token: z.string(), - res: z.record(z.any()), -}) - -export const GmailOAuthClientSchema = z.object({ - clientId: z.string().max(200), - clientSecret: z.string().max(200), - redirectURI: z.string().max(300), - refreshToken: z.string().max(500), - userEmail: z.string().email().max(150), - accessToken: GmailOAuthAccessTokenSchema.optional(), -}) diff --git a/app/src/types/oauth2client.types.ts b/app/src/types/oauth2client.types.ts deleted file mode 100644 index 0add10e..0000000 --- a/app/src/types/oauth2client.types.ts +++ /dev/null @@ -1,20 +0,0 @@ -export type { OAuth2Client } from 'google-auth-library' -export type { GetAccessTokenResponse } from 'google-auth-library/build/src/auth/authclient.js' - -/** - * Optional properties of the `OAuthClient` class constructor. - * The class constructor uses the values in the .env file by default. - * @interface IOauth2Client - * @param {string} [clientId] Google OAuth2 client ID - * @param {string} [clientSecret] Google OAuth2 secret - * @param {string} [redirectURI] Google OAuth2 redirect URI. This defaults to the Google OAuth2 Playground at "https://developers.google.com/oauthplayground." - * @param {string} [refreshToken] Google OAuth2 refresh token retrieved from the Google OAuth2 Playground. - * @param {string} [userEmail] Google email account that created the `clientId` and `clientSecret` - */ -export interface IOauthClient { - clientId: string; - clientSecret: string; - redirectURI: string; - refreshToken:string; - userEmail: string; -} diff --git a/app/src/types/transport.interface.ts b/app/src/types/transport.interface.ts index a83475a..9c26580 100644 --- a/app/src/types/transport.interface.ts +++ b/app/src/types/transport.interface.ts @@ -1,19 +1,20 @@ -import nodemailer from 'nodemailer' -import GmailOAuthClient from '@/lib/google/oauth2client.js' +import type { Transporter } from 'nodemailer' import type { SMTPTransport } from './transport.types.js' +import type { TransportOath2SchemaType } from './transport.schema.js' /** - * Public properties and methods types of the `GmailOAuthClient` class + * Public properties and methods types of the `EmailTransport` class * @interface IEmailTransport */ export interface IEmailTransport { /** * Initializes `this.#transporter` with a Nodemailer transport using the 3-Legged OAuth (3LO) authentication. - * @param {GmailOAuthClient} oauth2Client Instance of the `GmailOAuthClient` class - * @returns {Promise} A completed Promise that intialized `this.#transporter`with a Nodemailer transport + * Reads Google OAuth2 credentials from environment variables. + * @param {TransportOath2SchemaType} options Google OAuth2 settings + * @returns {Promise} A completed Promise that initialized `this.#transporter` with a Nodemailer transport * @see https://nodemailer.com/smtp/oauth2/#example-3 */ - createTransport3LO (oauth2Client: GmailOAuthClient): Promise; + createTransport3LO (options?: TransportOath2SchemaType): Promise; /** * Retrieves the options used to initialize the Nodemailer transport @@ -26,5 +27,5 @@ export interface IEmailTransport { * Retrieves the local Nodemailer transporter instance * @returns {nodemailer.Transporter | null} Nodemailer instance */ - get transporter (): nodemailer.Transporter | null; + get transporter (): Transporter | null; } diff --git a/app/src/types/transport.schema.ts b/app/src/types/transport.schema.ts new file mode 100644 index 0000000..53e4d52 --- /dev/null +++ b/app/src/types/transport.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' + +/** + * Google OAuth2 settings + * Uses the `GOOGLE_*` values in the .env file by default. + */ +export const TransportOath2Schema = z.object({ + googleUserEmail: z.string().email().max(150), + googleClientId: z.string().max(200), + googleClientSecret: z.string().max(200), + googleRereshToken: z.string().max(500), +}) + +export type TransportOath2SchemaType = z.infer; diff --git a/docs/README_NPM.md b/docs/README_NPM.md index 75d1acb..88139ff 100644 --- a/docs/README_NPM.md +++ b/docs/README_NPM.md @@ -72,7 +72,6 @@ We welcome contributions! Please see [CONTRIBUTING.md](/CONTRIBUTING.md) and the | GOOGLE_USER_EMAIL | Your Google email that you've configured for Gmail SMTP and Google OAuth2. | | GOOGLE_CLIENT_ID | Google OAuth2 client ID linked with your Google Cloud Platform project. | | GOOGLE_CLIENT_SECRET | Google OAuth2 client secret associated with the `GOOGLE_CLIENT_ID`. | - | GOOGLE_REDIRECT_URI | Allowed Google API redirect URI. Its value is `https://developers.google.com/oauthplayground` by default. | | GOOGLE_REFRESH_TOKEN | The initial (or any) refresh token obtained from the [OAuthPlayground](https://developers.google.com/oauthplayground).

Read on [Using the OAuth 2.0 Playground](https://github.com/weaponsforge/email-sender?tab=readme-ov-file#using-the-oauth-20-playground) for more information about generating a refresh token using the Google OAuth Playground.

_(⚠️ **INFO:** This is an older note; some steps may vary this 2025)_
|