Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 "<p style='color:red'>Hello</p>"

# 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
36 changes: 26 additions & 10 deletions app/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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': '^_',
},
],
},
},
])
4 changes: 2 additions & 2 deletions app/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
8 changes: 4 additions & 4 deletions app/src/__tests__/oauth2client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
})
})
1 change: 1 addition & 0 deletions app/src/__tests__/sample.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { test, expect } from 'vitest'

const greet = (str: string): string => {
console.log(str)

return 'hello'
}

Expand Down
32 changes: 16 additions & 16 deletions app/src/__tests__/schemavalidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
})
})
Expand All @@ -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 () => {
Expand All @@ -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()
})
})
20 changes: 10 additions & 10 deletions app/src/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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)
})
Loading
Loading