npm package: @postalsys/bounce-classifier
Description: SMTP bounce message classifier using machine learning
Author: Andris Reinman
Latest version: 2.4.0

# @postalsys/bounce-classifier

SMTP bounce message classifier using machine learning. Classifies email bounce/error messages into 16 categories.

Works in both **Node.js** and **browsers** - runs entirely client-side with no server required. Zero runtime dependencies.

**[Live Demo](https://postalsys.github.io/bounce-classifier/example/)** | **[Bounce Trainer](https://bounces.postalsys.com)** - submit labeled bounces to improve the classifier

> [!NOTE]
> This classifier was created for [EmailEngine](https://emailengine.app), a self-hosted email gateway that allows making REST requests against email accounts. For more information about how bounce classification integrates with EmailEngine, see the [messageBounce webhook documentation](https://learn.emailengine.app/docs/webhooks/messagebounce#bounce-categories).

## Installation

```bash
npm install @postalsys/bounce-classifier
```

## Usage

### ES Modules (Browser & Node.js)

```javascript
import { classify, initialize } from "@postalsys/bounce-classifier";

// Optional: pre-load the model
await initialize();

const result = await classify("550 5.1.1 User Unknown");
console.log(result.label); // 'user_unknown'
console.log(result.confidence); // 0.95
console.log(result.action); // 'remove'
```

### CommonJS (Node.js)

```javascript
const { classify } = require("@postalsys/bounce-classifier");

async function main() {
  const result = await classify("550 5.1.1 User Unknown");
  console.log(result);
}

main();
```

### Browser Usage

```html
<script type="module">
  import { classify, initialize } from "./src/index.js";

  // Specify model path for browser
  await initialize({ modelPath: "./model" });

  const result = await classify("550 5.1.1 User Unknown");
  console.log(result);
</script>
```

See the `example/` folder for a complete standalone browser demo that works offline.

## API

### `initialize(options?): Promise<void>`

Pre-load the model and vocabulary. Called automatically on first classification.

```javascript
// Node.js - uses bundled model automatically
await initialize();

// Browser - specify model path
await initialize({ modelPath: "./path/to/model" });
```

### `classify(message: string): Promise<ClassificationResult>`

Classify a single bounce message.

```javascript
const result = await classify("450 Greylisted, try again in 5 minutes");
// {
//   label: 'greylisting',
//   confidence: 0.947,
//   action: 'retry',
//   retryAfter: 300,  // seconds (only if timing found in message)
//   scores: { ... }
// }

const result2 = await classify("550 blocked using zen.spamhaus.org");
// {
//   label: 'ip_blacklisted',
//   confidence: 0.958,
//   action: 'retry_different_ip',
//   blocklist: { name: 'Spamhaus ZEN', type: 'ip' },
//   scores: { ... }
// }
```

### `getLabels(): Promise<string[]>`

Get list of all possible classification labels.

```javascript
const labels = await getLabels();
// ['auth_failure', 'domain_blacklisted', 'geo_blocked', ...]
```

### `reload(options?): Promise<void>`

Reload the model at runtime, optionally from a new path. This allows updating the model without restarting the process.

```javascript
// Reload from the same path (e.g., after retraining)
await reload();

// Switch to a different model directory
await reload({ modelPath: "/path/to/new-model" });
```

### `getModelInfo(): ModelInfo | null`

Get metadata about the loaded model. Returns `null` if not yet initialized.

```javascript
const info = getModelInfo();
// {
//   modelHash: '6b6a2c75307d59bf',    // truncated SHA-256 of weights
//   trainedAt: '2026-03-16T14:30:00Z', // ISO 8601 UTC
//   trainingSamples: 22630,
//   validationAccuracy: 0.9523
// }
```

### `isReady(): boolean`

Check if the classifier is initialized.

### `reset(): void`

Reset classifier state for re-initialization.

### Helper Functions

```javascript
import {
  extractRetryTiming,
  identifyBlocklist,
  getAction,
  extractSmtpCodes,
} from "@postalsys/bounce-classifier";

// Extract retry timing from message
const seconds = extractRetryTiming("try again in 5 minutes");
// 300

// Identify blocklists mentioned
const blocklist = identifyBlocklist("blocked by zen.spamhaus.org");
// { name: 'Spamhaus ZEN', type: 'ip' }

// Get recommended action for a label
const action = getAction("mailbox_full");
// 'retry'

// Extract SMTP codes
const codes = extractSmtpCodes("550 5.1.1 User unknown");
// { mainCode: '550', extendedCode: '5.1.1' }
```

## Labels

| Label                | Description                        | Action             |
| -------------------- | ---------------------------------- | ------------------ |
| `user_unknown`       | Recipient doesn't exist            | remove             |
| `invalid_address`    | Bad syntax, domain not found       | remove             |
| `mailbox_disabled`   | Account suspended/disabled         | remove             |
| `mailbox_full`       | Over quota, storage exceeded       | retry              |
| `greylisting`        | Temporary rejection, retry later   | retry              |
| `rate_limited`       | Too many connections/messages      | retry              |
| `server_error`       | Timeout, connection failed         | retry              |
| `ip_blacklisted`     | Sender IP on RBL                   | retry_different_ip |
| `domain_blacklisted` | Sender domain on blocklist         | fix_configuration  |
| `auth_failure`       | DMARC/SPF/DKIM failure             | fix_configuration  |
| `relay_denied`       | Relaying not permitted             | fix_configuration  |
| `spam_blocked`       | Message detected as spam           | review             |
| `policy_blocked`     | Local policy rejection             | review             |
| `virus_detected`     | Infected content detected          | remove_content     |
| `geo_blocked`        | Geographic/country-based rejection | retry_different_ip |
| `unknown`            | Unclassified bounce type           | review             |

## Custom Model Path

You can point the classifier to a different model directory, for example to use a retrained model:

```javascript
import {
  initialize,
  classify,
  reload,
  getModelInfo,
} from "@postalsys/bounce-classifier";

// Use a custom model at startup
await initialize({ modelPath: "/path/to/retrained-model" });

// Later, after retraining, reload the model without restarting
await reload();

// Check which model version is loaded
const info = getModelInfo();
console.log(info.modelHash); // '6b6a2c75307d59bf'
```

The model directory must contain `vocab.json`, `labels.json`, `group1-shard1of1.bin`, and `model.json`. The optional `config.json` provides metadata exposed through `getModelInfo()`.

## SMTP Code Fallback

When the ML model has low confidence (< 50%), the classifier falls back to SMTP status code-based classification using RFC 3463 enhanced status codes. This ensures reliable classification even for messages the model hasn't seen.

```javascript
const result = await classify("550 5.2.2 Over quota");
// If ML confidence is low, uses 5.2.2 -> mailbox_full fallback
// result.usedFallback will be true
```

## Running the Demo

The `example/` folder contains a browser demo. To run it:

```bash
cd example
npx serve ..
# Open http://localhost:3000/example/ in your browser
```

## Model Details

- **Architecture**: Embedding + GlobalAveragePooling + Dense layers
- **Vocabulary size**: 5,000 tokens
- **Max sequence length**: 100 tokens
- **Validation accuracy**: ~95%
- **Model size**: ~1.3 MB
- **Runtime**: Pure JavaScript (no native dependencies)

## License

MIT License - Copyright (c) Postal Systems OU