diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7df72ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`@weaponsforge/sendemail` is a Node.js NPM library and CLI for sending text and HTML emails via Gmail SMTP using Google OAuth2. It is distributed as an NPM package, Docker image, and Windows SEA (Single Executable Application) binary. + +## Development Setup + +All source code lives in the `/app` directory. Run all npm commands from `/app`. + +**Required environment variables** (copy `/app/.env.example` to `/app/.env`): +``` +GOOGLE_USER_EMAIL +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +GOOGLE_REDIRECT_URI # https://developers.google.com/oauthplayground +GOOGLE_REFRESH_TOKEN +``` + +## Common Commands + +All commands run from the `/app` directory: + +```bash +# Linting +npm run lint # Check for lint errors +npm run lint:fix # Auto-fix lint errors + +# Type checking / build +npm run transpile:noemit # Type-check only (no output) +npm run transpile # Compile TypeScript to dist/ + +# Testing (coverage always enabled, outputs to html/coverage/ and html/junit.xml) +npm test # Run all tests once +npm run dev # Vitest watch mode +npm run test:ui # Vitest UI dashboard (port 51204) + +# Run a single test file +npx vitest run src/__tests__/cli.test.ts + +# CLI (development) +npm run sendemail:dev -- text --recipients recipient@email.com --subject "Test" --content "Hello" +npm run sendemail:dev -- html --recipients recipient@email.com --subject "Test" --content "Paragraph 1" "Paragraph 2" +npm run sendemail:dev -- html --recipients recipient@email.com --subject "Test" --wysiwyg "

Hello

" + +# Optional --env flag to load a custom .env file +npm run sendemail:dev -- text --recipients r@email.com --subject "Test" --content "Hi" --env ./custom.env + +# Docker +npm run docker:debug # Run dev container with debugger (port 9229) +npm run docker:test:ui # Run Vitest UI in Docker (Linux/macOS) +npm run docker:test:ui:win # Run Vitest UI in Docker (Windows WSL2) +``` + +## Architecture + +### Class Hierarchy + +``` +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. +- **`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()` +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/`. + +### 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 + +### ESM Compatibility + +`globalThis.__dirname` is set in the CLI entry point (`scripts/cli/send.ts`) to handle `__dirname` in ESM. It resolves to `process.cwd()` in SEA mode or the module's directory otherwise. Other files that need `__dirname` use the `directory(import.meta.url)` helper from `utils/helpers.ts`. + +### Post-Build Step + +After `tsc` compiles to `dist/`, `npm run copy:files` (`scripts/build/copyTemplate.ts`) copies the EJS email template into `dist/` so it's available at runtime for non-SEA usage. + +### File Naming Conventions + +| Pattern | Purpose | +|---|---| +| `*.schema.ts` | Zod validation schemas | +| `*.interface.ts` | TypeScript interfaces | +| `*.types.ts` | TypeScript types and enums | +| `*.enum.ts` | Enums | + +### Path Aliases + +`@/` maps to `src/` (configured in `tsconfig.json`; resolved at runtime by `tsx`, and rewritten post-build by `tsc-alias`). + +## Code Style + +- No semicolons, single quotes, 2-space indentation, LF line endings +- Arrow functions preferred; early returns preferred +- Max ~250 lines per file +- TypeScript strict mode enabled +- No unused variables (prefix with `_` to suppress) +- Tests in `src/__tests__/` with `.test.ts` suffix + +## Build Outputs + +| Directory | Contents | +|---|---| +| `dist/` | Compiled JS + type declarations (npm package output) | +| `build/` | Windows SEA binary (`sendemail.exe`) | +| `html/coverage/` | Vitest coverage reports | +| `html/junit.xml` | JUnit test results (used by CI) | + +## CI/CD + +- **`test.yml`**: Runs on push to non-main branches — lint + type-check + tests (Node v24.11.0, ubuntu-latest) +- **`release.yml`**: Runs on GitHub release — lint + test + build SEA + publish to npm and Docker Hub +- Branch strategy: `dev` for development, `main` for releases diff --git a/app/eslint.config.mjs b/app/eslint.config.mjs index fa807a6..eef551f 100644 --- a/app/eslint.config.mjs +++ b/app/eslint.config.mjs @@ -8,23 +8,39 @@ export default defineConfig([ files: ['**/*.{js,mjs,cjs,ts,mts,cts}'], plugins: { js }, extends: ['js/recommended'], - languageOptions: { globals: globals.node } + languageOptions: { globals: globals.node }, }, tseslint.configs.recommended, { ignores: ['node_modules/**'] }, { rules: { - // 'no-unused-vars': 'off', - 'no-undef': 'error', - 'no-trailing-spaces': 'error', - '@typescript-eslint/no-unused-vars': ['error'], + 'comma-dangle': ['error', 'always-multiline'], + 'eol-last': ['error', 'always'], 'indent': ['error', 2], + 'keyword-spacing': ['error', { before: true, after: true }], 'linebreak-style': ['error', 'unix'], + 'object-curly-spacing': ['error', 'always'], 'quotes': ['error', 'single'], 'semi': ['error', 'never'], - 'comma-dangle': ['error', 'never'], - 'object-curly-spacing': ['error', 'always'], - 'eol-last': ['error', 'always'] - } - } + // 'no-console': ['error', { allow: ['error'] }], + 'no-multi-spaces': 'error', + 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }], + 'no-trailing-spaces': 'error', + 'no-undef': 'error', + 'no-unused-vars': 'off', + 'padding-line-between-statements': [ + 'error', + { blankLine: 'always', prev: '*', next: 'return' }, + ], + 'space-before-blocks': ['error', 'always'], + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + 'argsIgnorePattern': '^_', + 'varsIgnorePattern': '^_', + }, + ], + }, + }, ]) diff --git a/app/src/__tests__/cli.test.ts b/app/src/__tests__/cli.test.ts index 1566da9..0fb1c2d 100644 --- a/app/src/__tests__/cli.test.ts +++ b/app/src/__tests__/cli.test.ts @@ -15,10 +15,10 @@ describe('CLI test suite', () => { 'text', '-s', 'Hello, World!', '-c', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit...', - '-r', 'testor@gmail.com,abc@gmail.com' + '-r', 'testor@gmail.com,abc@gmail.com', ], { - preferLocal: true + preferLocal: true, }) expect(stdout).toContain('Process success') diff --git a/app/src/__tests__/oauth2client.test.ts b/app/src/__tests__/oauth2client.test.ts index 0e17bf3..5a7316a 100644 --- a/app/src/__tests__/oauth2client.test.ts +++ b/app/src/__tests__/oauth2client.test.ts @@ -26,7 +26,7 @@ describe('Google OAuth2 Client class test', () => { clientSecret: process.env.GOOGLE_CLIENT_SECRET, redirectURI: process.env.GOOGLE_REDIRECT_URI, refreshToken: process.env.GOOGLE_REFRESH_TOKEN, - userEmail: process.env.GOOGLE_USER_EMAIL + userEmail: process.env.GOOGLE_USER_EMAIL, }) const token = await oauthClient.getAccessToken() @@ -39,7 +39,7 @@ describe('Google OAuth2 Client class test', () => { // See @/types/oauth2client.schema.ts for the correct schema const wrongSchema = z.object({ hello: z.string(), - world: z.number() + world: z.number(), }) expect(() => new GmailOAuthClient(null, wrongSchema)).toThrow() @@ -50,11 +50,11 @@ describe('Google OAuth2 Client class test', () => { const accessToken = { token: 123, // Expected to be a string - res: 'hello' // Expected to be an object + res: 'hello', // Expected to be an object } as unknown as GetAccessTokenResponse expect(() => - oauthClient.accessToken = accessToken + oauthClient.accessToken = accessToken, ).toThrow() }) }) diff --git a/app/src/__tests__/sample.test.ts b/app/src/__tests__/sample.test.ts index 3a8ea97..1be124d 100644 --- a/app/src/__tests__/sample.test.ts +++ b/app/src/__tests__/sample.test.ts @@ -2,6 +2,7 @@ import { test, expect } from 'vitest' const greet = (str: string): string => { console.log(str) + return 'hello' } diff --git a/app/src/__tests__/schemavalidator.test.ts b/app/src/__tests__/schemavalidator.test.ts index f898e29..f19053d 100644 --- a/app/src/__tests__/schemavalidator.test.ts +++ b/app/src/__tests__/schemavalidator.test.ts @@ -12,7 +12,7 @@ describe('SchemaValidator class test - using zod (ZODOBJECT) schema', () => { const playerSchema = z.object({ name: z.string(), level: z.number(), - server: z.string() + server: z.string(), }) testSchema = new SchemaValidator(playerSchema) @@ -31,51 +31,51 @@ describe('SchemaValidator class test - using zod (ZODOBJECT) schema', () => { const typesafeUndefined = undefined as unknown as ZodObjectBasicType expect( - () => new SchemaValidator(typesafeUndefined) + () => new SchemaValidator(typesafeUndefined), ).toThrow() }) it ('should validate correct sub-schema when provided with a pick parameter', async () => { const validSubSchema = { server: 'some string', - level: 10 + level: 10, } expect(() => - testSchema!.validate({ data: validSubSchema, pick: true }) + testSchema!.validate({ data: validSubSchema, pick: true }), ).not.toThrow() }) it ('should throw an error when validating incorrect sub-schema with a pick parameter', async () => { const incorrectSubSchema = { otherKey: 'some string', - notAKey: 'some string' + notAKey: 'some string', } expect(() => - testSchema!.validate({ data: incorrectSubSchema, pick: true }) + testSchema!.validate({ data: incorrectSubSchema, pick: true }), ).toThrow() }) it ('should throw an error when validating incorrect schema data', async () => { const incorrectInputValues = { server: 'some string', - level: 'ten' + level: 'ten', } expect(() => - testSchema!.validate({ data: incorrectInputValues, pick: true }) + testSchema!.validate({ data: incorrectInputValues, pick: true }), ).toThrow() }) it ('should succeed when validating correct schema data', async () => { const correntInputValues = { server: 'some string', - level: 10 + level: 10, } expect(() => - testSchema!.validate({ data: correntInputValues, pick: true }) + testSchema!.validate({ data: correntInputValues, pick: true }), ).not.toThrow() }) }) @@ -85,10 +85,10 @@ describe('SchemaValidator class test - using zod (ZODEFFECTS) schema', () => { const effectsSchema = z.object({ id: z.number(), address: z.string(), - name: z.string() + name: z.string(), }).refine((data) => (data.address !== undefined), - { message: 'Address is required' } + { message: 'Address is required' }, ) it ('should extract properties from a ZodEffects schema', async () => { @@ -103,20 +103,20 @@ describe('SchemaValidator class test - using zod (ZODEFFECTS) schema', () => { const effectsSchema = z.object({ id: z.number(), address: z.string(), - name: z.string() + name: z.string(), }).refine((data) => (data.id !== undefined), - { message: 'ID is required' } + { message: 'ID is required' }, ) const testSchema = new SchemaValidator(effectsSchema) const data = { address: 123, - name: 'some string' + name: 'some string', } expect(() => - testSchema.validate({ data, pick: true }) + testSchema.validate({ data, pick: true }), ).toThrow() }) }) diff --git a/app/src/__tests__/send.test.ts b/app/src/__tests__/send.test.ts index bccd29c..17b67b4 100644 --- a/app/src/__tests__/send.test.ts +++ b/app/src/__tests__/send.test.ts @@ -18,7 +18,7 @@ describe('Send email test', () => { await expect(send({ recipient: TEST_RECIPIENT, subject: 'Test Simple Message', - content: 'Henlo!' + content: 'Henlo!', })).resolves.toBeUndefined() }, MAX_TIMEOUT) @@ -29,9 +29,9 @@ describe('Send email test', () => { { recipient: TEST_RECIPIENT, subject: 'Test Simple Message', - content: 'Henlo!' - }, oauthClient - ) + content: 'Henlo!', + }, oauthClient, + ), ).resolves.toBeUndefined() }, MAX_TIMEOUT) @@ -41,9 +41,9 @@ describe('Send email test', () => { { recipients: ['student1@gmail.com', 'student2@gmail.com'], subject: 'Test Multiple Message', - content: 'Henlo, hello!' - }, oauthClient - ) + content: 'Henlo, hello!', + }, oauthClient, + ), ).resolves.toBeUndefined() }, MAX_TIMEOUT) @@ -54,10 +54,10 @@ describe('Send email test', () => { recipients: ['person1@gmail.com', 'person2@gmail.com'], recipient: TEST_RECIPIENT, subject: 'Test Multiple Message 2', - content: 'Hello there' + content: 'Hello there', }, - oauthClient - ) + oauthClient, + ), ).resolves.toBeUndefined() }, MAX_TIMEOUT) }) diff --git a/app/src/__tests__/sendFormat.test.ts b/app/src/__tests__/sendFormat.test.ts index 6d0c98d..2065c38 100644 --- a/app/src/__tests__/sendFormat.test.ts +++ b/app/src/__tests__/sendFormat.test.ts @@ -26,10 +26,10 @@ describe('Email format test', () => { { recipient: invalidEmail, subject: TEST_SUBJECT, - content: 'Henlo!' + content: 'Henlo!', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL) }, MAX_TIMEOUT) @@ -42,10 +42,10 @@ describe('Email format test', () => { { recipient: longRecipientEmail, subject: TEST_SUBJECT, - content: 'Henlo!' + content: 'Henlo!', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH) }, MAX_TIMEOUT) @@ -58,10 +58,10 @@ describe('Email format test', () => { { recipient: TEST_RECIPIENT, subject: longTitle, - content: 'Henlo!' + content: 'Henlo!', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.SUBJECT) }, MAX_TIMEOUT) @@ -74,10 +74,10 @@ describe('Email format test', () => { { recipient: TEST_RECIPIENT, subject: TEST_SUBJECT, - content: longContent + content: longContent, }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.CONTENT) }, MAX_TIMEOUT) @@ -87,10 +87,10 @@ describe('Email format test', () => { send( { subject: TEST_SUBJECT, - content: 'Hello there' + content: 'Hello there', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_REQUIRED) }, MAX_TIMEOUT) @@ -103,10 +103,10 @@ describe('Email format test', () => { { recipients: emailList, subject: TEST_SUBJECT, - content: 'Hello there' + content: 'Hello there', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_MAX) }, MAX_TIMEOUT) @@ -119,10 +119,10 @@ describe('Email format test', () => { recipients: emailList, recipient: TEST_RECIPIENT, subject: TEST_SUBJECT, - content: 'Hello there' + content: 'Hello there', }, - oauthClient - ) + oauthClient, + ), ).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_MAX) }, MAX_TIMEOUT) }) diff --git a/app/src/demo/sendEmail.ts b/app/src/demo/sendEmail.ts index 78d6c2a..703463f 100644 --- a/app/src/demo/sendEmail.ts +++ b/app/src/demo/sendEmail.ts @@ -5,6 +5,6 @@ import { send } from '@/lib/index.js' await send({ recipient: 'tester@gmail.com', subject: 'Test Message', - content: 'How are you?' + content: 'How are you?', }) })() diff --git a/app/src/demo/sendHtml.ts b/app/src/demo/sendHtml.ts index bf84c4c..8b80825 100644 --- a/app/src/demo/sendHtml.ts +++ b/app/src/demo/sendHtml.ts @@ -10,7 +10,7 @@ import dataJson from '@/utils/templates/data.json' with { type: 'json' } const emailContent = await buildHtml({ content: [dataJson.content], recipients: dataJson.recipients, - sender: process.env.GOOGLE_USER_EMAIL + sender: process.env.GOOGLE_USER_EMAIL, }) // Send the email @@ -18,6 +18,6 @@ import dataJson from '@/utils/templates/data.json' with { type: 'json' } subject: dataJson.subject, content: emailContent, recipients: dataJson.recipients, - isHtml: true + isHtml: true, }) })() diff --git a/app/src/index.ts b/app/src/index.ts index 97b5bb5..de0bd2d 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -4,5 +4,5 @@ export { GmailOAuthClient, SchemaValidator, buildHtml, - send + send, } from './lib/index.js' diff --git a/app/src/lib/email/build.ts b/app/src/lib/email/build.ts index b3960ec..3127d15 100644 --- a/app/src/lib/email/build.ts +++ b/app/src/lib/email/build.ts @@ -15,14 +15,14 @@ type EmailBuildOptions = Omit * @returns {Promise} HTML-form email content */ export const buildHtml = async ( - params: EmailBuildOptions + params: EmailBuildOptions, ): Promise => { const { content: messages = [], recipients, sender, wysiwyg = null, - sanitizeConfig + sanitizeConfig, } = params HtmlBuildSchema.parse(params) @@ -37,7 +37,7 @@ export const buildHtml = async ( wysiwyg .replace(/(\r\n|\n|\r|\t)/g, '') .trim(), - configOptions + configOptions, ) } @@ -68,7 +68,7 @@ export const buildHtml = async ( recipient, messages: cleanMessages, sender, - wysiwyg: wysiwygHtml + wysiwyg: wysiwygHtml, }) return html diff --git a/app/src/lib/email/send.ts b/app/src/lib/email/send.ts index ec77ec3..10ee793 100644 --- a/app/src/lib/email/send.ts +++ b/app/src/lib/email/send.ts @@ -14,7 +14,7 @@ export const send = async (params: EmailType, client?: GmailOAuthClient): Promis const handler = new EmailSender({ host: TRANSPORT_SMTP_HOSTS.GMAIL, - type: TRANSPORT_AUTH_TYPES.OAUTH2 + type: TRANSPORT_AUTH_TYPES.OAUTH2, }) try { @@ -31,7 +31,7 @@ export const send = async (params: EmailType, client?: GmailOAuthClient): Promis recipients, subject, content, - isHtml + isHtml, }) const acceptedCount = (result?.accepted || []).length diff --git a/app/src/lib/email/sender.ts b/app/src/lib/email/sender.ts index 9ed67c7..14d6991 100644 --- a/app/src/lib/email/sender.ts +++ b/app/src/lib/email/sender.ts @@ -31,7 +31,7 @@ class EmailSender extends EmailTransport implements IEmailSender { recipients = [], subject, content, - isHtml = false + isHtml = false, } = params const receivers = stringsToArray(recipient, recipients) @@ -44,8 +44,8 @@ class EmailSender extends EmailTransport implements IEmailSender { from: transportOptions.auth?.user || process.env.GOOGLE_USER_EMAIL, to: receivers, subject, - ...(!isHtml && { text: content }), // Text email - ...(isHtml && { html: content }) // HTML content format + ...(!isHtml && { text: content }), // Text email + ...(isHtml && { html: content }), // HTML content format }) } catch (err: unknown) { if (err instanceof Error) { diff --git a/app/src/lib/email/transport.ts b/app/src/lib/email/transport.ts index 740c507..956a6ca 100644 --- a/app/src/lib/email/transport.ts +++ b/app/src/lib/email/transport.ts @@ -49,8 +49,8 @@ class EmailTransport implements IEmailTransport { clientId: oauth2Client.client?._clientId, clientSecret: oauth2Client.client?._clientSecret, refreshToken: oauth2Client?.refreshToken, - accessToken: token - } + accessToken: token, + }, }) } catch (err: unknown) { if (err instanceof Error) { @@ -66,10 +66,10 @@ class EmailTransport implements IEmailTransport { throw new Error('Transport not initialized') } - return this.#transporter.options + return this.#transporter.options } - get transporter (): nodemailer.Transporter | null { + get transporter (): nodemailer.Transporter | null { return this.#transporter } } diff --git a/app/src/lib/google/oauth2client.ts b/app/src/lib/google/oauth2client.ts index e2c6828..9e7deee 100644 --- a/app/src/lib/google/oauth2client.ts +++ b/app/src/lib/google/oauth2client.ts @@ -10,7 +10,7 @@ import { GmailOAuthClientSchema } from '@/types/oauth2client.schema.js' import { type GetAccessTokenResponse, type IOauthClient, - type OAuth2Client + type OAuth2Client, } from '@/types/oauth2client.types.js' /** @@ -53,7 +53,7 @@ class GmailOAuthClient implements IGmailOAuthClient { clientSecret, redirectURI, refreshToken, - userEmail + userEmail, }) } @@ -66,7 +66,7 @@ class GmailOAuthClient implements IGmailOAuthClient { this.#client = new google.auth.OAuth2( clientId, clientSecret, - redirectURI + redirectURI, ) this.#client.setCredentials({ refresh_token: refreshToken }) @@ -84,6 +84,7 @@ class GmailOAuthClient implements IGmailOAuthClient { async getAccessToken (): Promise { this.checkClient() + return await this.#client!.getAccessToken() } @@ -103,7 +104,7 @@ class GmailOAuthClient implements IGmailOAuthClient { this.#schema?.validate({ data: { accessToken }, - pick: true + pick: true, }) this.#accessToken = accessToken diff --git a/app/src/lib/index.ts b/app/src/lib/index.ts index 1b97f1c..fa06f26 100644 --- a/app/src/lib/index.ts +++ b/app/src/lib/index.ts @@ -10,5 +10,5 @@ export { EmailSender, EmailTransport, GmailOAuthClient, - SchemaValidator + SchemaValidator, } diff --git a/app/src/lib/validator/schemavalidator.ts b/app/src/lib/validator/schemavalidator.ts index f2c4cb7..8e332bc 100644 --- a/app/src/lib/validator/schemavalidator.ts +++ b/app/src/lib/validator/schemavalidator.ts @@ -7,7 +7,7 @@ import type { ZodIssue, ZodObjectBasicType, ZodRawShape, - ZodSchemaType + ZodSchemaType, } from '@/types/schemavalidator.interface.js' /** @@ -72,9 +72,9 @@ class SchemaValidator implements ISchemaValidator { } if (this.isZodEffectsSchema(this.schema!)) { - schema = this.getBaseSchema(this.schema!) + schema = this.getBaseSchema(this.schema!) } else { - schema = this.schema + schema = this.schema } const originalKeys = Object.keys(schema.shape) @@ -111,16 +111,17 @@ class SchemaValidator implements ISchemaValidator { if (result && !result.success) { const errorMessage = this.formatErrors( result?.error?.errors, - errorDelimiter + errorDelimiter, ) - throw new Error(errorMessage || 'Encountered email parameter validation errors') + throw new Error(errorMessage || 'Encountered email parameter validation errors') } } formatErrors (errors: ZodIssue[], errorDelimiter: string = '\n'): string { return errors.reduce((list, item) => { const message = `${item.message} ${item.path[0]} ${errorDelimiter}` + return list + message }, '') } @@ -133,17 +134,19 @@ class SchemaValidator implements ISchemaValidator { get typeName (): string { this.checkSchema() - return (this.schema)._def.typeName + + return ( this.schema)._def.typeName } get properties (): string[] { this.checkSchema() if (this.isZodEffectsSchema(this.schema!)) { - const schema = this.getBaseSchema(this.schema!) + const schema = this.getBaseSchema(this.schema!) + return Object.keys(schema.shape) } else { - return Object.keys((this.schema).shape) + return Object.keys(( this.schema).shape) } } } diff --git a/app/src/scripts/build/copyTemplate.ts b/app/src/scripts/build/copyTemplate.ts index 9d8383e..5ee8142 100644 --- a/app/src/scripts/build/copyTemplate.ts +++ b/app/src/scripts/build/copyTemplate.ts @@ -14,7 +14,7 @@ const main = async () => { try { await copyFiles(outDir, [ - path.join(templatesDir, 'email.ejs') + path.join(templatesDir, 'email.ejs'), ]) } catch (err: unknown) { errMsg = (err instanceof Error) ? err.message : 'Something went wrong' diff --git a/app/src/scripts/cli/lib/handleHtml.ts b/app/src/scripts/cli/lib/handleHtml.ts index 3479d13..2f28d51 100644 --- a/app/src/scripts/cli/lib/handleHtml.ts +++ b/app/src/scripts/cli/lib/handleHtml.ts @@ -15,7 +15,7 @@ export const handleSendHtmlEmail = async (options: EmailHtmlOptions) => { subject, content: paragraphs = [], recipients = [], - wysiwyg = null + wysiwyg = null, } = options if (paragraphs.length === 0 && typeof wysiwyg !== 'string') { @@ -31,14 +31,14 @@ export const handleSendHtmlEmail = async (options: EmailHtmlOptions) => { content: paragraphs, recipients: emails, sender: process.env.GOOGLE_USER_EMAIL, - wysiwyg + wysiwyg, }) await send({ subject, content: emailContent, recipients: emails, - isHtml: true + isHtml: true, }) } catch (err: unknown) { if (err instanceof Error) { diff --git a/app/src/scripts/cli/lib/handleText.ts b/app/src/scripts/cli/lib/handleText.ts index 4395b25..5d6a634 100644 --- a/app/src/scripts/cli/lib/handleText.ts +++ b/app/src/scripts/cli/lib/handleText.ts @@ -14,7 +14,7 @@ export const handleSendTextEmail = async (options: EmailTextOptions) => { await send({ recipients, subject, - content + content, }) } catch (err: unknown) { if (err instanceof Error) { diff --git a/app/src/scripts/cli/lib/meta.ts b/app/src/scripts/cli/lib/meta.ts index 27c1658..cbe09c6 100644 --- a/app/src/scripts/cli/lib/meta.ts +++ b/app/src/scripts/cli/lib/meta.ts @@ -2,16 +2,16 @@ const CLI_META = { PROGRAM: { NAME: 'sendemail', - DESCRIPTION: 'CLI for sending text and HTML emails using Gmail SMTP and Google OAuth2' + DESCRIPTION: 'CLI for sending text and HTML emails using Gmail SMTP and Google OAuth2', }, CMD_SEND_TEXT: { NAME: 'text', - DESCRIPTION: 'Send raw text email to one or multiple recipient/s' + DESCRIPTION: 'Send raw text email to one or multiple recipient/s', }, CMD_SEND_HTML: { NAME: 'html', - DESCRIPTION: 'Send paragraphs of text or WYSIWYG content as styled\nHTML email to one or multiple recipient/s.' - } + DESCRIPTION: 'Send paragraphs of text or WYSIWYG content as styled\nHTML email to one or multiple recipient/s.', + }, } as const // CLI command arguments @@ -20,44 +20,44 @@ const CLI_ARGS = { COMMON: { SUBJECT: { OPTION: '-s, --subject ', - DESCRIPTION: 'email subject or title enclosed in double-quotes' + DESCRIPTION: 'email subject or title enclosed in double-quotes', }, RECIPIENTS: { OPTION: '-r, --recipients <emails>', - DESCRIPTION: 'comma-separated list of email addresses' + DESCRIPTION: 'comma-separated list of email addresses', }, ENV_FILE: { OPTION: '-e, --env [path]', - DESCRIPTION: 'path to .env file (optional)' - } + DESCRIPTION: 'path to .env file (optional)', + }, }, // Text content CMD_TEXT: { CONTENT: { OPTION: '-c, --content <text>', - DESCRIPTION: 'email text content enclosed in double-quotes' - } + DESCRIPTION: 'email text content enclosed in double-quotes', + }, }, // HTML content CMD_HTML: { CONTENT_HTML: { OPTION: '-c, --content <text...>', - DESCRIPTION: 'whitespace-delimited text/paragraphs enclosed in double-quotes' + DESCRIPTION: 'whitespace-delimited text/paragraphs enclosed in double-quotes', }, CONTENT_WYSIWYG: { OPTION: '-w, --wysiwyg [html]', - DESCRIPTION: 'optional HTML tags that form a WYSIWYG content enclosed in double-quotes, using inline CSS styles' + DESCRIPTION: 'optional HTML tags that form a WYSIWYG content enclosed in double-quotes, using inline CSS styles', }, TEMPLATE_FILE: { OPTION: '-t, --template [path]', - DESCRIPTION: 'optional path to an EJS template file' - } - } + DESCRIPTION: 'optional path to an EJS template file', + }, + }, } as const export { CLI_ARGS, - CLI_META + CLI_META, } diff --git a/app/src/scripts/cli/send.ts b/app/src/scripts/cli/send.ts index 576d665..e221bab 100644 --- a/app/src/scripts/cli/send.ts +++ b/app/src/scripts/cli/send.ts @@ -33,7 +33,7 @@ program.command(CLI_META.CMD_SEND_TEXT.NAME) .requiredOption( CLI_ARGS.COMMON.RECIPIENTS.OPTION, CLI_ARGS.COMMON.RECIPIENTS.DESCRIPTION, - (val) => val.split(',').map(s => s.trim()).filter(Boolean) + (val) => val.split(',').map(s => s.trim()).filter(Boolean), ) .action(async (options: EmailTextOptions) => { const { env } = options @@ -56,7 +56,7 @@ program.command(CLI_META.CMD_SEND_HTML.NAME) .requiredOption( CLI_ARGS.COMMON.RECIPIENTS.OPTION, CLI_ARGS.COMMON.RECIPIENTS.DESCRIPTION, - (val) => val.split(',').map(s => s.trim()).filter(Boolean) + (val) => val.split(',').map(s => s.trim()).filter(Boolean), ) .option(CLI_ARGS.CMD_HTML.CONTENT_HTML.OPTION, CLI_ARGS.CMD_HTML.CONTENT_HTML.DESCRIPTION) .option(CLI_ARGS.CMD_HTML.CONTENT_WYSIWYG.OPTION, CLI_ARGS.CMD_HTML.CONTENT_WYSIWYG.DESCRIPTION) diff --git a/app/src/types/email.schema.ts b/app/src/types/email.schema.ts index dd1fdc7..c358028 100644 --- a/app/src/types/email.schema.ts +++ b/app/src/types/email.schema.ts @@ -31,7 +31,7 @@ export const BaseEmailSchema = z.object({ recipients: z.array( z.string({ message: EmailSchemaMessages.RECIPIENT_EMAIL }) .email({ message: EmailSchemaMessages.RECIPIENT_EMAIL }) - .max(150, { message: EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH }) + .max(150, { message: EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH }), ).max(20, { message: EmailSchemaMessages.RECIPIENT_EMAIL_MAX }) .optional(), @@ -43,13 +43,13 @@ export const BaseEmailSchema = z.object({ isHtml: z.boolean() .default(false) - .optional() + .optional(), }) export const EmailSchema = BaseEmailSchema.refine( (data: IOptionalParams) => data.recipient !== undefined || (data.recipients !== undefined && data.recipients.length > 0), - { message: EmailSchemaMessages.RECIPIENT_REQUIRED } + { message: EmailSchemaMessages.RECIPIENT_REQUIRED }, ) /** @@ -99,29 +99,30 @@ export const HtmlBuildSchema = BaseEmailSchema .omit({ recipient: true, isHtml: true, subject: true }) .extend({ content: z.array( - z.string().max(1500, { message: EmailSchemaMessages.CONTENT }) + z.string().max(1500, { message: EmailSchemaMessages.CONTENT }), ).optional(), recipients: z.array( z.string() .email({ message: EmailSchemaMessages.RECIPIENT_EMAIL }) - .max(150, { message: EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH }) + .max(150, { message: EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH }), ).max(20, { message: EmailSchemaMessages.RECIPIENT_EMAIL_MAX }), sender: z.string({ message: EmailSchemaMessages.RECIPIENT_EMAIL }) .email({ message: EmailSchemaMessages.RECIPIENT_EMAIL }) .max(150, { message: EmailSchemaMessages.RECIPIENT_EMAIL_LENGTH }), - wysiwyg: z.string().nullable().optional() + wysiwyg: z.string().nullable().optional(), }) .refine( (data) => { const hasContent = data.content && data.content.length > 0 const hasWysiwyg = data.wysiwyg && data.wysiwyg.trim().length > 0 + return hasContent || hasWysiwyg }, { message: 'Either \'content\' or \'wysiwyg\' must be provided', - path: ['content', 'wysiwyg'] - } + path: ['content', 'wysiwyg'], + }, ) diff --git a/app/src/types/oauth2client.interface.ts b/app/src/types/oauth2client.interface.ts index bbbd008..c887fd9 100644 --- a/app/src/types/oauth2client.interface.ts +++ b/app/src/types/oauth2client.interface.ts @@ -3,7 +3,7 @@ import SchemaValidator from '@/lib/validator/schemavalidator.js' import type { GetAccessTokenResponse, IOauthClient, - OAuth2Client + OAuth2Client, } from '@/types/oauth2client.types.js' /** diff --git a/app/src/types/oauth2client.schema.ts b/app/src/types/oauth2client.schema.ts index 98a0cde..8a117cc 100644 --- a/app/src/types/oauth2client.schema.ts +++ b/app/src/types/oauth2client.schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod' export const GmailOAuthAccessTokenSchema = z.object({ token: z.string(), - res: z.record(z.any()) + res: z.record(z.any()), }) export const GmailOAuthClientSchema = z.object({ @@ -11,5 +11,5 @@ export const GmailOAuthClientSchema = z.object({ redirectURI: z.string().max(300), refreshToken: z.string().max(500), userEmail: z.string().email().max(150), - accessToken: GmailOAuthAccessTokenSchema.optional() + accessToken: GmailOAuthAccessTokenSchema.optional(), }) diff --git a/app/src/utils/config/sanitizeHtml.ts b/app/src/utils/config/sanitizeHtml.ts index e1bb066..b9c5d79 100644 --- a/app/src/utils/config/sanitizeHtml.ts +++ b/app/src/utils/config/sanitizeHtml.ts @@ -8,7 +8,7 @@ import type { IOptions } from 'sanitize-html' const config: IOptions = { allowedAttributes: { - '*': ['style'] // allow style attribute on all tags + '*': ['style'], // allow style attribute on all tags }, allowedStyles: { // Allow specific CSS properties on specific tags @@ -17,7 +17,7 @@ const config: IOptions = { 'width': [/^(\d+(?:px|%|em|rem|vh|vw))$/], 'height': [/^(\d+(?:px|%|em|rem|vh|vw))$/], 'border': [ - /^(\d+(?:px|em|rem)?\s+(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)\s+(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|[a-z]+))$/ + /^(\d+(?:px|em|rem)?\s+(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)\s+(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|[a-z]+))$/, ], 'border-radius': [/^(\d+(?:px|%|em|rem)?)$/], 'padding': [/^(\d+(?:px|%|em|rem)?( ?\d+(?:px|%|em|rem)?)*)$/], @@ -26,17 +26,17 @@ const config: IOptions = { // Text alignment & color 'text-align': [/^(left|right|center|justify)$/], 'background-color': [ - /^(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|(?:transparent|white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|brown|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver|azure))$/ + /^(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|(?:transparent|white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|brown|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver|azure))$/, ], 'color': [ - /^(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|(?:transparent|white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|brown|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver))$/ + /^(?:#[0-9a-fA-F]{3,6}|rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)|rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*[\d.]+\s*\)|(?:transparent|white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|brown|cyan|magenta|lime|navy|teal|olive|maroon|aqua|fuchsia|silver))$/, ], // Font styles 'font-family': [/^[\w\s'",.-]+$/], - 'font-size': [/^(\d+(?:px|%|em|rem|pt))$/] - } - } + 'font-size': [/^(\d+(?:px|%|em|rem|pt))$/], + }, + }, } export default config diff --git a/app/src/utils/helpers.ts b/app/src/utils/helpers.ts index 261615f..ced7f14 100644 --- a/app/src/utils/helpers.ts +++ b/app/src/utils/helpers.ts @@ -44,9 +44,8 @@ interface IRandomTextArrayParams { export const createRandomTextArray = (params: IRandomTextArrayParams) => { const { length = 1, prefix = '', suffix = '' } = params - // eslint-disable-next-line @typescript-eslint/no-unused-vars return Array.from({ length }).map(_ => - `${prefix}${Math.random().toString(36).substring(2, 8)}${suffix}` + `${prefix}${Math.random().toString(36).substring(2, 8)}${suffix}`, ) } @@ -83,6 +82,6 @@ export const loadEnv = (pathToEnv: string | undefined) => { dotenv.config({ path: pathToEnv, - quiet: true + quiet: true, }) } diff --git a/app/vite.config.ts b/app/vite.config.ts index a806466..35ad9a1 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -4,13 +4,13 @@ import { configDefaults, defineConfig } from 'vitest/config' export default defineConfig({ resolve: { alias: { - '@': resolve(__dirname, 'src') - } + '@': resolve(__dirname, 'src'), + }, }, test: { reporters: ['verbose', 'html', 'junit'], outputFile: { - junit: './html/junit.xml' + junit: './html/junit.xml', }, coverage: { provider: 'v8', @@ -23,8 +23,8 @@ export default defineConfig({ 'html/', '**/*.html', '**/*.md', - '**/*.ejs' - ] + '**/*.ejs', + ], }, exclude: [ ...configDefaults.exclude, @@ -33,7 +33,7 @@ export default defineConfig({ '.cursor/**', '.git/**', '.github/**', - '.vscode/**' - ] - } + '.vscode/**', + ], + }, })