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
3 changes: 2 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,6 @@ jobs:
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
# No install step: there is no build, the package is published as-is
# installs the dev dependencies, npm publish then builds dist/ from src/ through the prepare script
- run: npm ci --ignore-scripts
- run: npm publish --provenance --access public
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,42 @@ jobs:
- run: npm ci
- name: Run tests
run: npm run test:unit

# The package is also used from Bun and Deno: the same tests run under their latest releases
bun:
name: Tests (Bun)
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: npm ci
- name: Run tests
run: npm run test:bun

deno:
name: Tests (Deno)
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Use Node.js 24
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- run: npm ci
- name: Run tests
run: npm run test:deno
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules
.DS_Store
*.log
.claude/*
dist/
*.tsbuildinfo
10 changes: 10 additions & 0 deletions .ncurc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use strict';

module.exports = {
// Node 20 is the supported runtime floor (see "engines" in package.json). A dependency major that
// needs a newer Node has to be capped here with `target` or `reject`.
upgrade: true,
// @types/node stays on the 20.x line so the compiler rejects APIs that Node 20 does not have, and
// typescript on 6.x like nodemailer, a move to the native TypeScript 7 compiler is a separate change
target: name => (name === '@types/node' || name === 'typescript' ? 'minor' : 'latest')
};
8 changes: 0 additions & 8 deletions .ncurc.js

This file was deleted.

1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
package-lock.json
CHANGELOG.md
dist
4 changes: 1 addition & 3 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
'use strict';

module.exports = {
export default {
printWidth: 160,
tabWidth: 4,
singleQuote: true,
Expand Down
25 changes: 17 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,36 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project

`imap-handler` parses complete IMAP command strings into a structured object and compiles such objects back into IMAP strings. It is not a streaming parser: the whole command, including literals, must be buffered first, and syntax errors throw. It is used by hoodiecrow-imap (the IMAP mock server), so a parser or compiler change can break that consumer. CommonJS, no runtime dependencies, supports Node.js 20 and newer (CI tests 20, 22 and 24).
`imap-handler` parses complete IMAP command strings into a structured object and compiles such objects back into IMAP strings. It is not a streaming parser: the whole command, including literals, must be buffered first, and syntax errors throw. It is used by ImapKit (`imapkit` on npm, the IMAP mock server in ../imapkit), so a parser or compiler change can break that consumer. Written in TypeScript under `src/` and published as a dual package (ES modules in `dist/esm/`, CommonJS in `dist/cjs/`, each with type declarations), no runtime dependencies, supports Node.js 20 and newer (CI tests 20, 22 and 24) and the latest Bun and Deno.

## Commands

- `npm test`: ESLint, then all tests (`npm run test:unit`, which is `node --test test/*.js`).
- Single test file: `node --test test/parser.js`. Single test case: add `--test-name-pattern="<test name>"`.
- `npm test`: ESLint and the type check (`npm run lint`), then all tests (`npm run test:unit`, which builds and runs `node --import tsx --test test/*.test.ts`).
- `npm run build`: `scripts/build.js` compiles `src/` with `tsconfig.esm.json` and `tsconfig.cjs.json` and rewrites the CommonJS modules that only have a default export so that `require()` returns the function or object itself (`require('imap-handler/lib/parser')` is the parser). `dist/` is gitignored, `prepare` builds it on install and publish.
- `npm run test:bun`, `npm run test:deno`: the same tests under Bun and Deno (CI runs both on their latest release).
- Single test file: `node --import tsx --test test/parser.test.ts`. Single test case: add `--test-name-pattern="<test name>"`.
- `npm run lint`, `npm run format` / `npm run format:check` (Prettier: single quotes, 4 spaces, 160 columns). CI fails on unformatted files. `npm install` sets `core.hooksPath` to `.githooks`, whose pre-commit hook runs Prettier on staged files.
- `npm run update`: refresh all dev dependencies to latest (`ncu -u`, config in `.ncurc.js`). Versions are pinned exactly.

ESLint (`eslint.config.js`) enforces `const`/`let` (no `var`), arrow callbacks, one declaration per statement, `===`, and global `'use strict'`.
ESLint (`eslint.config.js`, with typescript-eslint) enforces `const`/`let` (no `var`), arrow callbacks, one declaration per statement and `===`.

## TypeScript and module format

- Every file under `src/` compiles both as ES module and as CommonJS, so it must not use `import.meta`, `require`, `module`, `exports`, `__dirname`, `__filename`, top-level `await` or JSON imports. Relative imports carry the `.js` extension, builtins use the `node:` prefix.
- `erasableSyntaxOnly` is on (no enums, no parameter properties), `@types/node` stays on the 20.x line (`.ncurc.cjs`) so APIs newer than Node 20 do not type-check.
- `src/parser.ts`, `src/compiler.ts` and `src/formal.ts` have only a default export, keep it that way (the CommonJS shape depends on it, `test/package.test.ts` checks it). `src/index.ts` has the named exports `parser` and `compiler`, its default export is the exports object.
- `package.json` `exports` maps `.`, `./lib/*` and `./lib/*.js` to both builds.

## Releases

Releases are automated with release-please: use Conventional Commit messages (`fix:`, `feat:`, `chore:` ...) on master, merge the release PR it opens, and `.github/workflows/release.yaml` waits for the `test.yml` run on that commit and then publishes to npm through trusted publishing (OIDC, no token). Do not bump `version` in package.json by hand.

## Architecture

- `lib/parser.js`: `ParserInstance` reads the tag, the command (joining `options.multiWords` such as `UID FETCH` into one command) and hands the rest to `TokenParser`, a character-by-character state machine that builds a node tree and then walks it into the plain `attributes` array returned to the caller, with upper-cased types (`ATOM`, `STRING`, `LITERAL`, `LITERAL8`, `SEQUENCE`, `LIST`, `SECTION`, `PARTIAL`). Options: `allowUntagged`, `allowSection`, `multiWords`, `literalPlus`, `literal8` (accept `~{n}`), `utf8` (accept UTF-8 in quoted strings). The default is strict RFC 3501 grammar, extensions are opt-in.
- `lib/compiler.js`: the inverse, turns `{ tag, command, attributes }` back into an IMAP string, choosing quoting or literals per value. It also accepts `TEXT` nodes (written unquoted), which the parser never produces, and `LITERAL8` nodes. The optional second argument `{ utf8: true }` quotes valid UTF-8 values instead of writing literals.
- `lib/formal.js`: RFC 3501 character classes (`ATOM-CHAR`, `DIGIT`, ...) used by both, memoized on first call, plus a `verify` helper.
- `src/parser.ts`: `ParserInstance` reads the tag, the command (joining `options.multiWords` such as `UID FETCH` into one command) and hands the rest to `TokenParser`, a character-by-character state machine that builds a node tree and then walks it into the plain `attributes` array returned to the caller, with upper-cased types (`ATOM`, `STRING`, `LITERAL`, `LITERAL8`, `SEQUENCE`, `LIST`, `SECTION`, `PARTIAL`). Options: `allowUntagged`, `allowSection`, `multiWords`, `literalPlus`, `literal8` (accept `~{n}`), `utf8` (accept UTF-8 in quoted strings). The default is strict RFC 3501 grammar, extensions are opt-in.
- `src/compiler.ts`: the inverse, turns `{ tag, command, attributes }` back into an IMAP string, choosing quoting or literals per value. It also accepts `TEXT` nodes (written unquoted), which the parser never produces, and `LITERAL8` nodes. The optional second argument `{ utf8: true }` quotes valid UTF-8 values instead of writing literals.
- `src/formal.ts`: RFC 3501 character classes (`ATOM-CHAR`, `DIGIT`, ...) used by both, memoized on first call, plus a `verify` helper.

## Tests

Tests use `node:test` and `node:assert` and are synchronous. Parse failures are asserted with `assert.throws(() => parser(...))`; do not use the `try { ...; assert.ok(false) } catch {}` pattern, since the catch would swallow the assertion failure.
Tests (`test/*.test.ts`) use `node:test` and `node:assert` and are synchronous, and import from `../src/index.js`. `test/package.test.ts` loads the built `dist/` through the `exports` map. Parse failures are asserted with `assert.throws(() => parser(...))`; do not use the `try { ...; assert.ok(false) } catch {}` pattern, since the catch would swallow the assertion failure.
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,20 @@ Key-value pairs are also not identified, all lists are parsed into arrays, not o
npm install imap-handler
```

IMAP Handler requires Node.js 20 or newer.
IMAP Handler requires Node.js 20 or newer. It also runs on the latest Bun and Deno releases, which CI tests as well.

The package is written in TypeScript and ships both ES modules and CommonJS, each with type declarations:

```javascript
// ES modules
import { parser, compiler } from 'imap-handler';
// or: import imapHandler from 'imap-handler';

// CommonJS
const { parser, compiler } = require('imap-handler');
```

The modules are also available on their own: `imap-handler/lib/parser` and `imap-handler/lib/compiler` export the function, and `imap-handler/lib/formal` the RFC 3501 character classes (`formal.tag()`, `formal['ATOM-CHAR']()`, ...). With `require()` these load as the function or object itself, with `import` as the default export. Types such as `ParsedCommand`, `ParserOptions` and `CompilerInput` are exported from the package root.

> IMAP Handler is maintained by the team behind **[EmailEngine](https://emailengine.app/?utm_source=imap-handler-readme&utm_medium=readme&utm_campaign=oss-docs&utm_content=note)**, a self-hosted email API that turns Gmail, Microsoft 365, and IMAP accounts into REST endpoints, with managed OAuth2 and webhooks for incoming mail. For a full featured IMAP client, see [ImapFlow](https://imapflow.com/).

Expand Down Expand Up @@ -76,7 +89,7 @@ Syntax errors throw an `Error` with `code` set to `"ParserError"` (or `"MaxNesti
For example

```javascript
var imapHandler = require('imap-handler');
const imapHandler = require('imap-handler');

imapHandler.parser('A1 FETCH *:4 (BODY[HEADER.FIELDS ({4}\r\nDate Subject)]<12.45> UID)');
```
Expand Down Expand Up @@ -157,7 +170,7 @@ bodies.adjacentLists = true;
For example

```javascript
var command = {
const command = {
tag: '*',
command: 'OK',
attributes: [
Expand All @@ -175,9 +188,13 @@ imapHandler.compiler(command);

## Development

The source is in `src/`, `npm run build` compiles it into `dist/esm` and `dist/cjs`.

npm install
npm test # lint + all tests
npm test # lint, type check and all tests
npm run test:unit # tests only
npm run test:bun # tests under Bun
npm run test:deno # tests under Deno
npm run format # apply Prettier formatting

## License
Expand Down
43 changes: 33 additions & 10 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
'use strict';
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';

const js = require('@eslint/js');
const globals = require('globals');

module.exports = [
export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', '.claude/**']
},
js.configs.recommended,
{
files: ['**/*.js'],
files: ['**/*.js', '**/*.cjs', '**/*.ts'],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'commonjs',
sourceType: 'module',
globals: {
...globals.node
}
Expand All @@ -21,8 +24,28 @@ module.exports = [
'prefer-const': 'error',
'prefer-arrow-callback': 'error',
'one-var': ['error', 'never'],
eqeqeq: ['error', 'always', { null: 'ignore' }],
eqeqeq: ['error', 'always', { null: 'ignore' }]
}
},
{
files: ['**/*.cjs'],
languageOptions: {
sourceType: 'commonjs'
},
rules: {
strict: ['error', 'global']
}
}
];
},
{
files: ['**/*.ts'],
extends: [tseslint.configs.recommended],
rules: {
// handled by the TypeScript compiler
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }],
'@typescript-eslint/no-explicit-any': 'off'
}
},
prettier
);
6 changes: 0 additions & 6 deletions index.js

This file was deleted.

Loading