# Mailman Tasks API: guide for AI coding assistants

This file is a complete, self-contained description of Mailman's hosted Tasks API. If you are an AI assistant and a developer gave you this file (or its URL) to write or review a bot integration, treat everything here as the specification and follow the MUST and SHOULD rules exactly. The human-oriented docs are at https://getmailman.app/developers. Mailman's local agent API, which is a different thing, is described at https://getmailman.app/agents.md.

## What the API does

A bot needs a verification code or a login link that a website emailed to an address. Instead of logging in to the mailbox, the bot calls the Tasks API:

1. **Create** a mail task: the address the email is sent to, filters that identify the email, and what to extract from it. The API answers with a task `id`.
2. **Mailman**, a desktop app running on the user's computer, watches that inbox, finds the email and extracts the values. Mail and mailbox passwords never leave the user's computer.
3. **Poll** for the completed tasks of a group. Each finished task has a `status` and, on success, `results`: a map from the element names the bot asked for to the extracted text.

The API uses the common Inbox Tasks protocol: the same paths, `Token` header and task JSON. A bot that already speaks it works after its host is changed (see "Existing Inbox Tasks clients").

## Facts

| Item | Value |
| --- | --- |
| Base URL | `https://tasks.getmailman.app`. One host for every user: hardcode it. |
| Authentication | `Authorization: Token <key>` on every request. The key is the user's own: Mailman → Settings → Tasks API → Copy. |
| Format | JSON, UTF-8. A `Content-Type` header on create is optional. |
| Transport | HTTPS, server to server. There are no CORS headers: never call the API from browser JavaScript. |
| Endpoints | `POST /api/v1/tasks/mail/create`, `GET /api/v1/tasks/completed?group=<group>`, `GET /api/v1/user/verify` |
| Requires | The user has Mailman 0.1.35 or newer on their computer, signed in with an active license, open and connected. |

A key works only while the user's Mailman is open and connected. Otherwise every call answers `401`. Users give a bot only their API key, never an address.

## Official SDKs

Use the official SDK for production integrations in Node.js/TypeScript, Python or Go. All three are version 0.1.0 and MIT licensed. Keep the reference clients for other languages and for understanding the protocol. AI assistants: prefer the official SDK when the bot uses Node.js/TypeScript, Python or Go; use the reference clients below otherwise.

Set MAILMAN_TASKS_KEY to the user's key; their Mailman must be open and connected. These examples assume the email is triggered independently after run starts.

### Node.js / TypeScript

[Package and API docs](https://www.npmjs.com/package/mailman-tasks) · [Source on GitHub](https://github.com/chefsonya/mailman-tasks-node)

```sh
npm install mailman-tasks@0.1.0
```

```js
import { MailmanTasks, codeTask } from "mailman-tasks";

const client = new MailmanTasks(process.env.MAILMAN_TASKS_KEY, { group: "signup-bot" });
await client.verify();
const { results } = await client.run(codeTask({
  email: "alias@example.com", from: "noreply@example.com",
  subjectIncludes: "verification code",
}));
console.log(results.code ?? "no code found");
```

### Python

[Package and API docs](https://pypi.org/project/mailman-tasks/) · [Source on GitHub](https://github.com/chefsonya/mailman-tasks-python)

```sh
python -m pip install mailman-tasks==0.1.0
```

```python
import os
from mailman_tasks import MailmanTasks, code_task

client = MailmanTasks(os.environ["MAILMAN_TASKS_KEY"], group="signup-bot")
client.verify()
result = client.run(code_task(
    email="alias@example.com", from_="noreply@example.com",
    subject_includes="verification code", length=6,
))
print(result["results"].get("code", "no code found"))
```

### Go

[Package and API docs](https://pkg.go.dev/github.com/chefsonya/mailman-tasks-go) · [Source on GitHub](https://github.com/chefsonya/mailman-tasks-go)

```sh
go get github.com/chefsonya/mailman-tasks-go@v0.1.0
```

```go
package main

import (
    "context"
    "fmt"
    "log"
    "os"

    mailmantasks "github.com/chefsonya/mailman-tasks-go"
)

func main() {
    ctx := context.Background()
    client := mailmantasks.New(os.Getenv("MAILMAN_TASKS_KEY"),
        mailmantasks.WithGroup("signup-bot"))
    if err := client.Verify(ctx); err != nil { log.Fatal(err) }
    result, err := client.Run(ctx, mailmantasks.CodeTask(
        "alias@example.com", "noreply@example.com", "verification code", 6))
    if err != nil { log.Fatal(err) }
    if code, found := result.Results["code"]; found {
        fmt.Println(code)
    } else {
        fmt.Println("no code found")
    }
}
```

## What you need to know before writing code

Ask the developer, or infer from their request, before you generate an integration:

1. The language and HTTP library the bot uses (the reference clients below use only standard libraries).
2. What the website emails: the sender address or domain, words in the subject, and whether the bot needs a code (digits or letters) or a link.
3. How many waits run at the same time. One polling loop per group scales to thousands of tasks.
4. Where the user's API key comes from (a setting or environment variable).

## Rules

MUST rules. Breaking one gives wrong results or rejected requests.

- **M1.** Send `Authorization: Token <key>` on every request: the word `Token`, a space, then the key. Other prefixes, such as `Bearer`, are rejected with `401`.
- **M2.** Hardcode the host `https://tasks.getmailman.app`. Never ask users for an address. Never disable TLS certificate verification.
- **M3.** Read the API key from the user's configuration. Never hardcode it in source, never log it, never put it in a URL.
- **M4.** `receivedAt`, `timeout` and every other time value are in **seconds**. JavaScript's `Date.now()` is milliseconds: divide by 1000. A millisecond value is accepted and then no email can ever match.
- **M5.** Space every HTTP attempt to `GET /api/v1/tasks/completed?group=<group>` by at least 3 seconds per group, including internal retries. Only immediately after a full page of 100 may the next attempt use 1 second. Wait the longest of this minimum, `Retry-After`, and exponential backoff.
- **M6.** Poll per group, not per task. Create as many tasks as you need in one group, run one polling loop for that group, and match results to tasks by `id`. Two loops on the same group split the results between them, and each task's result goes to only one of them.
- **M7.** Results are delivered at most once. A result leaves the server in the same step that puts it in the response. Don't abort a poll you have already sent because a wait deadline passes: finish reading it and store every result before checking or reporting that deadline. Store every result the moment each page arrives, including ids nobody is waiting on, in one persistent per-group store for up to 1 hour. Satisfy later waits from that store. Keep collected results accessible after later failures. There is no way to fetch them again.
- **M8.** Give every wait its own monotonic deadline: the task's `timeout` plus about 15 seconds. It decides whether to send another poll (including retries) and caps sleeps; it never shortens a poll already sent. Every HTTP request has its own total 15-second timeout covering connect, headers and body. Let a sent poll run to its own timeout and store its results before checking the wait deadline. A wait may finish up to one request timeout (15 seconds here) after its deadline if the network stalls. When the wait ends, report missing tasks as lost; recovery requires a fresh email before a new task. The server restarts now and then and drops pending tasks, and a response can be lost in transit.
- **M9.** Retry creates only on explicit server rejections: `429` with `Retry-After`, or `503`. Never automatically retry a create after a dropped connection, reset, client-side timeout after the request may have been sent, or any ambiguous response. GETs may retry transport failures and `408` as well. Retry at most 5 times with exponential backoff (1, 2, 4, 8, 16 seconds); always wait the longest of backoff, `Retry-After`, and M5 for polls. Cap retry sleeps at the wait deadline and do not send a new poll after it. Never use the wait deadline to abort a poll already sent; its own request timeout still applies. A create's own deadline may still abort its request.
- **M10.** Don't retry `400` or `413`: the request is wrong. Don't retry `429 pending task limit exceeded` without first collecting results.
- **M11.** Treat `401` as "the key is wrong, or the user's Mailman isn't open and connected". The API doesn't say which. While polling, wait about 10 seconds and keep going until your deadline. When creating a task, stop and tell the user.
- **M12.** Escape backslashes in JSON strings. The regular expression `\b\d{6}\b` is written `"\\b\\d{6}\\b"`. A single `\b` in JSON is a backspace character and matches nothing.
- **M13.** URL-encode the `group` value in the query string.

SHOULD rules.

- **S1.** Call `GET /api/v1/user/verify` when a user pastes a key into your bot, and before a long run.
- **S2.** Always set `timeout` (seconds) to how long you are willing to wait. The default and maximum is 3600.
- **S3.** Set `receivedAt` to the Unix time just before you trigger the email, so older mail can't answer the task.
- **S4.** Narrow the filters so only the right email matches: the sender (`from` includes the address or domain) and a distinctive word of the subject.
- **S5.** After a lost result, trigger a fresh email (for example the site's "resend code") before you recreate an identical task. Mailman uses each email once per address and filter set for an hour, so an identical task can't take an email it already answered with.
- **S6.** Send the same `group` on every task of one bot or job, and poll that group. Use a different group per independent polling loop.
- **S7.** If your bot supports more than one inbox provider, choose the host from a provider setting and ask for the matching key.
- **S8.** Keep the 401 message friendly: "The key was rejected, or Mailman isn't open on your computer."

## Endpoints

### POST /api/v1/tasks/mail/create

Creates a task. The answer comes at once with status `pending`; the result arrives later from the completed endpoint. The body is JSON, at most 64 KiB. Fields the API doesn't know are ignored.

```ts
/** Request body. */
interface MailTask {
  /** The address the email is sent to. Required, at most 1024 characters.
   *  Mailman answers from the inbox that owns this address or alias.
   *  Only mail sent to this exact address answers the task. */
  email: string;
  /** Unix time in SECONDS, above 0. Only mail received at or after it can answer the task. Required. */
  receivedAt: number;
  /** 1 to 10 filters that pick the email. Required. */
  mailFilters: MailFilter[];
  /** 1 to 10 values to extract from it. Required. */
  mailElements: MailElement[];
  /** Your label for tasks that are polled together. At most 1024 characters. Default "". */
  group?: string;
  /** Seconds the task may run, counted from creation. Default and maximum 3600:
   *  missing, 0 and larger values all mean 3600. */
  timeout?: number;
  /** A template the user created in Mailman to choose which inboxes answer.
   *  Omit it unless the user set one up: an unknown id fails the task with
   *  errorStatus "invalid task template". At most 1024 characters. */
  templateId?: string;
}

interface MailFilter {
  /** What to compare. For "from", the sender is "Name <address>" when the email has a display
   *  name, otherwise just the address. */
  target: "from" | "subject";
  comparator: "includes" | "excludes" | "matches" | "findByRegex"; // findByRegex means matches
  /** Text or pattern. Not empty, at most 1024 characters. */
  value: string;
  /** Up to 10 alternatives: the filter passes if value or any entry passes
   *  (for "excludes": if none of them appears). */
  orValues?: string[];
  /** Optional filters are not required to match. */
  optional?: boolean;
  /** A regular expression whose matches are removed from the target before comparing. */
  regexReplaceAll?: string;
}

interface MailElement {
  /** The key this value gets in results. Not empty. */
  name: string;
  /** "from" is the sender's address (lowercase), "subject" the subject, "body" the email body. */
  target: "from" | "subject" | "body";
  /** Body only: a CSS selector for the part of the HTML to read.
   *  A selector that starts with "/", "./" or "(" is read as XPath.
   *  A "body" element needs a selector or a regex. */
  selector?: string;
  /** Applied to the value. Every match counts. If the pattern has a capture group, group 1 is the
   *  value, otherwise the whole match. */
  regex?: string;
  /** Body element with a regex and no selector: "pretty" (default) runs on the readable text of
   *  the email, "raw" on the HTML exactly as sent. */
  regexSource?: "pretty" | "raw";
  /** How a selector match becomes a value. Default "text". */
  formatter?: "text" | "html" | "link" | "numeric";
  /** Accepted for compatibility with existing clients and ignored. */
  penetrate?: boolean;
}
```

How tasks are matched and extracted:

- Mailman watches the user's inbox and, where it knows the provider's spam folder, that folder too. It takes the newest email received at or after `receivedAt` that passes the filters.
- Every filter that isn't `optional` must match. If every filter is optional, one match is enough.
- `includes` and `excludes` compare text and ignore case. `matches` and `findByRegex` treat `value` as a regular expression that must match somewhere in the target; it is case-sensitive for `subject` and case-insensitive for `from`. Regular expressions are Perl-style (lookahead works) and are compiled on the user's computer.
- Formatters: `text` is the visible text of the matched element with whitespace collapsed, `html` is the element's HTML, `link` is the `href` of the element or of the first link inside it, `numeric` is only the digits of the element's text.
- A `success` result always includes a `results` object, empty (`{}`) when no element extracted anything. An element that finds nothing is left out of `results`. Several different matches are joined with newlines. Each value is cut at 8192 characters.
- A bad regular expression or selector is not an HTTP error. The task finishes with an `error` result whose `errorStatus` is `invalid mail task`.

Request:

```json
{
  "group": "signup-bot",
  "email": "alias@example.com",
  "receivedAt": 1793145600,
  "timeout": 120,
  "mailFilters": [
    { "target": "from", "comparator": "includes", "value": "noreply@example.com" },
    { "target": "subject", "comparator": "includes", "value": "verification code" }
  ],
  "mailElements": [
    { "name": "otpCode", "target": "body", "regex": "([0-9]{6})" }
  ]
}
```

Response, `200`:

```json
{ "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "pending" }
```

Patterns for `mailElements`:

```json
[
  { "name": "code", "target": "body", "regex": "\\b\\d{6}\\b" },
  { "name": "code", "target": "body", "selector": ".otp-code strong", "formatter": "numeric" },
  { "name": "code", "target": "body", "regex": ">(\\d{6})<", "regexSource": "raw" },
  { "name": "code", "target": "subject", "regex": "\\b\\d{6}\\b" },
  { "name": "loginUrl", "target": "body", "selector": "a[href*='/login']", "formatter": "link" },
  { "name": "key", "target": "body", "selector": "a[href*='/login']", "regex": "&key=([a-zA-Z0-9]+)", "formatter": "link" },
  { "name": "sender", "target": "from" }
]
```

Each line is an alternative, not a list to send together: pick the one that fits the site's email.

### GET /api/v1/tasks/completed

Returns the finished tasks of one group, up to 100 per call, oldest first. Pass the same `group` the tasks were created with; leave the parameter out for tasks created without a group.

```
GET /api/v1/tasks/completed?group=signup-bot
```

```ts
interface CompletedResponse {
  tasks: TaskResult[]; // {"tasks":[]} when nothing has finished
}

type TaskResult = { id: string } & (
  | { status: "success"; results: Record<string, string> } // {} if nothing extracted
  | { status: "error"; results?: Record<string, string> }
  | { status: "timeout"; results?: never }
);
```

```json
{
  "tasks": [
    { "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "success", "results": { "otpCode": "123456" } },
    { "id": "a41c9e02d7b3485f9e6c1d2b3a4f5e60", "status": "timeout" }
  ]
}
```

- Delivery is once, per M7. An uncollected result is dropped after 3600 seconds, and each key and group keeps its newest 50000.
- When a task reaches its `timeout`, the server completes it as `timeout`, without `results`. A late answer from Mailman is ignored.
- Error results carry these `results` values:

| `errorStatus` | `errorMessage` | When |
| --- | --- | --- |
| `invalid task template` | The task template provided is invalid. Please ensure you make a task template that maps to the templateId provided. | The `templateId` is unknown, or no inbox matches the template or `email`. |
| `invalid mail task` | `<field>: <reason>`, for example `mailElements[0].regex: …` | The user's Mailman can't compile a selector or regular expression. |
| `internal error` | `Desktop task capacity reached` | The user's Mailman is already running 10000 tasks. |
| `internal error` | `result discarded: server busy` | The result couldn't be kept. Create the task again. |
| `internal error` | Other text | A local failure on the user's computer while looking up the inbox. |

### GET /api/v1/user/verify

Answers `200` with `{}` when the key is valid and the user's Mailman is open and connected. Answers `401` otherwise. Call it when a user pastes a key and before a long run.

## Errors

Error bodies normally use `{"error": "message"}`. Some `400` and `413` responses may have non-JSON bodies (including HTML), and a stalled request may be closed without an answer or remain unanswered until your own timeout. Check the HTTP status before parsing; handle empty and non-JSON error bodies as errors too. Do not rely on an immediate rejection from a declared body length. `404`, `405` and `408` normally have no body.

| Status | Message | What to do |
| --- | --- | --- |
| 400 | `invalid task JSON` | Fix the request. The body isn't JSON a task can be read from. |
| 400 | A validation message such as `email is required`, `mailFilters has too many entries`, `mailFilters[0].comparator has invalid value "contains"` | Fix the request. The message names the field. |
| 400 | `invalid group query` | Fix the query string, for example `group` given twice. |
| 400 | May be non-JSON for a malformed `Content-Length` | Fix the header. |
| 401 | `invalid user API key` | Header missing or malformed, unknown key, or the key's Mailman isn't open and connected. While polling: wait, keep going until the deadline. At create: tell the user. |
| 404, 405 | none | Wrong path or HTTP method. |
| 408 | none | If received, the request timed out. A stalled upload is not guaranteed a response. GETs may retry with backoff; do not automatically retry creates. |
| 413 | `request body exceeds 64 KiB or could not be read` | Make the task smaller. |
| 429 | `rate limit exceeded`, with `Retry-After: 1` to `60` | Retry after the longest of `Retry-After`, backoff and the polling minimum (M5). |
| 429 | `pending task limit exceeded`, no `Retry-After` | The key has 10000 pending tasks. Collect results or let tasks time out. |
| 500 | `random generator failed` | Surface the error; do not automatically retry creates. |
| 503 | `server busy`, `Retry-After: 5` (create only) | Wait 5 seconds, then retry. |
| 503 | `server shutting down`, no `Retry-After` | Retry the rejected request with backoff. Previous pending tasks may be lost: use their deadlines and fresh emails before replacement. |

Rate limits are two fixed 60-second windows: 5000 requests a minute per IP address (every request counts, including ones that fail with `401`, so bots behind one shared address share it) and 10000 requests a minute per key (create, completed and verify calls). `Retry-After` is the number of seconds until the window resets. A group polled every 3 seconds costs 20 requests a minute, however many tasks are waiting in it.

## Limits

| What | Limit |
| --- | --- |
| Create request body | 64 KiB |
| Any string in a task | 1024 characters |
| Filters, elements, and `orValues` per filter | 10 each |
| Task `timeout` | 3600 seconds (also the default) |
| Tasks returned by one poll | 100, oldest first |
| A result value | 8192 characters, longer values are cut |
| Uncollected results | dropped after 3600 seconds; newest 50000 per key and group kept |
| Pending tasks per key | 10000 |
| Requests per minute | 5000 per IP address, 10000 per key |
| Time for one request | Use your own 15-second total timeout; stalled requests may receive no answer |

## Reference clients

Complete clients that use only the standard library. Each one verifies the key, creates a task, polls at the right pace for one group (M5, M6), waits out `Retry-After` and backs off (M9), treats a `401` while polling as "Mailman isn't running right now" (M11), uses its wait deadline only to gate new polls and cap sleeps, with an independent 15-second total timeout per request (M8), and shows that a second poll returns nothing (M7). Use one client per group, serialize calls to its polling methods, and keep it alive across waits. The public `results` (`Results` in Go) store holds every page even when a later wait throws; read it when handling failures. Expired entries are pruned on polls and waits; an idle client performs no background work. Adapt the usage block at the bottom to the site your bot targets. To test against another host, change only the host constant.

### Node.js

```js
// Node.js 18+, no dependencies.  Run: MAILMAN_TASKS_KEY=<key> node client.mjs
const HOST = "https://tasks.getmailman.app";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.ceil(ms)));

class MailmanError extends Error {
  constructor(status, body, retryAfter) {
    super(`Tasks API ${status}: ${body}`);
    this.status = status; // HTTP status
    this.retryAfter = retryAfter; // seconds from Retry-After, 0 if absent
  }
}

class MailmanTasks {
  constructor(key, { group = "default", host = HOST } = {}) {
    Object.assign(this, { key, group, host });
    this.results = new Map(); // retained even if a later request fails
    this.expires = new Map();
    this.nextPoll = performance.now() + 3000;
    this.pollGap = 3000;
  }

  // Creates retry only explicit 429/503 rejections; never ambiguous transport failures.
  async request(method, path, body, deadline = Infinity) {
    const poll = path.startsWith("/api/v1/tasks/completed");
    let retryDelay = 0;
    for (let attempt = 0; ; attempt++) {
      const now = performance.now();
      const delay = Math.max(retryDelay, poll ? this.nextPoll - now : 0, 0);
      if (delay >= deadline - now) {
        await sleep(Math.max(0, deadline - now));
        throw new Error("task lost: deadline expired"); // no room for another paced attempt
      }
      await sleep(delay);
      if (performance.now() >= deadline) throw new Error("task lost: deadline expired");
      let res;
      try {
        res = await fetch(this.host + path, {
          method,
          headers: { Authorization: `Token ${this.key}`, "Content-Type": "application/json" },
          body: body === undefined ? undefined : JSON.stringify(body),
          signal: AbortSignal.timeout(Math.max(1, Math.ceil(poll ? 15_000 : Math.min(15_000, deadline - performance.now())))),
        });
      } catch (err) {
        if (method !== "GET" || attempt >= 5) throw err;
        retryDelay = 2 ** attempt * 1000;
        continue;
      } finally {
        if (poll) {
          this.pollGap = 3000; // the full-page allowance applies to one attempt only
          this.nextPoll = performance.now() + this.pollGap;
        }
      }
      if (res.ok) return res.json();
      const text = await res.text();
      const retryAfter = Number(res.headers.get("retry-after")) || 0;
      const retry = (res.status === 503 || (method === "GET" && res.status === 408)) || (res.status === 429 && retryAfter > 0);
      if (!retry || attempt >= 5) throw new MailmanError(res.status, text, retryAfter);
      retryDelay = Math.max(retryAfter, 2 ** attempt) * 1000;
    }
  }

  verify() {
    return this.request("GET", "/api/v1/user/verify");
  }

  // Creates a task in this client's group and returns its id.
  async createTask(task) {
    const { id } = await this.request("POST", "/api/v1/tasks/mail/create", { ...task, group: this.group });
    return id;
  }

  // Keep one client per group; serialize waits and polls through it.
  // Every page is stored before returning, including ids no caller requested.
  // The deadline gates sends/retries, never the body of a poll already sent.
  async completed(deadline = Infinity) {
    const { tasks } = await this.request("GET", `/api/v1/tasks/completed?group=${encodeURIComponent(this.group)}`, undefined, deadline);
    this.pollGap = tasks.length === 100 ? 1000 : 3000;
    this.nextPoll = performance.now() + this.pollGap;
    for (const task of tasks) {
      this.results.set(task.id, task);
      this.expires.set(task.id, performance.now() + 3600_000);
    }
    this.prune();
    return tasks;
  }

  prune() {
    for (const [id, expiry] of this.expires) if (expiry <= performance.now()) {
      this.results.delete(id);
      this.expires.delete(id);
    }
  }

  async waitForResults(ids, timeoutSeconds = 3600) {
    const deadline = performance.now() + (timeoutSeconds + 15) * 1000;
    this.prune();
    while (ids.some((id) => !this.results.has(id)) && performance.now() < deadline) {
      try {
        await this.completed(deadline);
      } catch (err) {
        if (performance.now() >= deadline) break;
        if (err.status !== 401) throw err; // collected results remain in mail.results
        this.nextPoll = performance.now() + 10_000;
      }
    }
    return new Map(ids.filter((id) => this.results.has(id)).map((id) => [id, this.results.get(id)]));
  }
}

// ---- Usage ----
const mail = new MailmanTasks(process.env.MAILMAN_TASKS_KEY, { group: "signup-bot" });

try {
  await mail.verify();
} catch (err) {
  if (err.status !== 401) throw err;
  console.error("The key was rejected, or Mailman isn't open on the user's computer.");
  process.exit(1);
}

const timeout = 120;
const id = await mail.createTask({
  email: "alias@example.com",
  receivedAt: Math.floor(Date.now() / 1000),
  timeout,
  mailFilters: [
    { target: "from", comparator: "includes", value: "noreply@example.com" },
    { target: "subject", comparator: "includes", value: "verification code" },
  ],
  mailElements: [{ name: "otpCode", target: "body", regex: "([0-9]{6})" }],
});

const result = (await mail.waitForResults([id], timeout)).get(id);
if (result?.status === "success") console.log("code:", result.results.otpCode ?? "no code found");
else console.log("no code:", result ?? "lost");

console.log("a second poll returns", (await mail.completed()).length, "tasks"); // 0: delivered once
```

### Python

```python
# Python 3.8+, standard library only.  Run: MAILMAN_TASKS_KEY=<key> python client.py
import http.client
import queue
import socket
import threading
import json
import os
import sys
import time
from urllib.parse import quote, urlsplit

HOST = "https://tasks.getmailman.app"


def fetch_text(method, url, data=None, key="", deadline=float("inf")):
    """A 15s total request budget, also bounded by the caller's absolute deadline.

    The caller owns the clock, including DNS/connect, headers and a trickling body.
    Shutdown interrupts active socket I/O; a late DNS lookup cannot send a request.
    """
    deadline = min(deadline, time.monotonic() + 15)
    parts = urlsplit(url)
    connection = (http.client.HTTPSConnection if parts.scheme == "https" else http.client.HTTPConnection)(
        parts.hostname, parts.port, timeout=max(0.001, deadline - time.monotonic()))
    done, cancelled, sockets = queue.Queue(maxsize=1), threading.Event(), []

    def exchange():
        try:
            connection.connect()
            sock = connection.sock
            sockets.append(sock)
            if cancelled.is_set() or time.monotonic() >= deadline:
                raise TimeoutError("request deadline expired")
            connection.request(method, parts.path + ("?" + parts.query if parts.query else ""),
                               body=data, headers={"Authorization": f"Token {key}",
                                                   "Content-Type": "application/json"})
            response = connection.getresponse()
            with response:
                text = response.read().decode("utf-8", errors="replace")
                done.put((response.status, response.headers, text))
        except Exception as err:
            done.put(err)
        finally:
            connection.close()

    # Daemon only matters if the system resolver itself stalls; Python cannot cancel DNS.
    threading.Thread(target=exchange, daemon=True).start()
    try:
        answer = done.get(timeout=max(0, deadline - time.monotonic()))
    except queue.Empty:
        raise TimeoutError("request deadline expired") from None
    finally:
        cancelled.set()
        for sock in sockets:
            try:
                sock.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
    if time.monotonic() >= deadline:
        raise TimeoutError("request deadline expired")
    if isinstance(answer, Exception):
        raise answer
    return answer


class MailmanError(Exception):
    def __init__(self, status, body, retry_after=0):
        super().__init__(f"Tasks API {status}: {body}")
        self.status = status  # HTTP status
        self.retry_after = retry_after  # seconds from Retry-After, 0 if absent


class MailmanTasks:
    def __init__(self, key, group="default", host=HOST):
        self.key, self.group, self.host = key, group, host
        self.results, self.expires = {}, {}  # survive later failures
        self.next_poll, self.poll_gap = time.monotonic() + 3, 3

    def request(self, method, path, body=None, deadline=float("inf")):
        """Creates retry only explicit 429/503 rejections, never transport failures."""
        poll = path.startswith("/api/v1/tasks/completed")
        retry_delay = 0
        data = None if body is None else json.dumps(body).encode()
        for attempt in range(6):
            now = time.monotonic()
            delay = max(retry_delay, self.next_poll - now if poll else 0, 0)
            time.sleep(min(delay, max(0, deadline - now)))
            if time.monotonic() >= deadline:
                raise TimeoutError("task lost: deadline expired")
            try:
                status, headers, text = fetch_text(method, self.host + path, data,
                                                   key=self.key, deadline=float("inf") if poll else deadline)
                if status == 200:
                    return json.loads(text)
                header = headers.get("Retry-After", "")
                retry_after = int(header) if header.isdigit() else 0
                retry = (status == 503 or (method == "GET" and status == 408)) or (status == 429 and retry_after > 0)
                if not retry or attempt == 5:
                    raise MailmanError(status, text, retry_after)
                retry_delay = max(retry_after, 2 ** attempt)
            except (OSError, http.client.HTTPException):
                if method != "GET" or attempt == 5:
                    raise
                retry_delay = 2 ** attempt

            finally:
                if poll:
                    self.poll_gap = 3  # a full page permits only the very next attempt at 1s
                    self.next_poll = time.monotonic() + self.poll_gap

    def verify(self):
        return self.request("GET", "/api/v1/user/verify")

    def create_task(self, task):
        """Creates a task in this client's group and returns its id."""
        return self.request("POST", "/api/v1/tasks/mail/create", {**task, "group": self.group})["id"]

    # Keep one client per group; serialize waits and polls through it.
    # The deadline gates sends/retries, never the body of a poll already sent.
    def completed(self, deadline=float("inf")):
        tasks = self.request("GET", f"/api/v1/tasks/completed?group={quote(self.group, safe='')}", deadline=deadline)["tasks"]
        self.poll_gap = 1 if len(tasks) == 100 else 3
        self.next_poll = time.monotonic() + self.poll_gap
        for task in tasks:  # store even ids nobody requested
            self.results[task["id"]] = task
            self.expires[task["id"]] = time.monotonic() + 3600
        self.prune()
        return tasks

    def prune(self):
        for task_id, expiry in list(self.expires.items()):
            if expiry <= time.monotonic():
                self.results.pop(task_id, None)
                del self.expires[task_id]

    def wait_for_results(self, ids, timeout_seconds=3600):
        deadline = time.monotonic() + timeout_seconds + 15
        self.prune()
        while any(i not in self.results for i in ids) and time.monotonic() < deadline:
            try:
                self.completed(deadline)
            except Exception as err:
                if time.monotonic() >= deadline:
                    break
                if not isinstance(err, MailmanError) or err.status != 401:
                    raise  # collected results remain in mail.results
                self.next_poll = time.monotonic() + 10
        return {i: self.results[i] for i in ids if i in self.results}


# ---- Usage ----
if __name__ == "__main__":
    mail = MailmanTasks(os.environ["MAILMAN_TASKS_KEY"], group="signup-bot")

    try:
        mail.verify()
    except MailmanError as err:
        if err.status != 401:
            raise
        sys.exit("The key was rejected, or Mailman isn't open on the user's computer.")

    timeout = 120
    task_id = mail.create_task({
        "email": "alias@example.com",
        "receivedAt": int(time.time()),
        "timeout": timeout,
        "mailFilters": [
            {"target": "from", "comparator": "includes", "value": "noreply@example.com"},
            {"target": "subject", "comparator": "includes", "value": "verification code"},
        ],
        "mailElements": [{"name": "otpCode", "target": "body", "regex": "([0-9]{6})"}],
    })

    result = mail.wait_for_results([task_id], timeout).get(task_id)
    if result and result["status"] == "success":
        print("code:", result["results"].get("otpCode", "no code found"))
    else:
        print("no code:", result or "lost")

    print("a second poll returns", len(mail.completed()), "tasks")  # 0: delivered once
```

### Go

```go
// Go 1.21+, standard library only.  Run: MAILMAN_TASKS_KEY=<key> go run client.go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"
)

const host = "https://tasks.getmailman.app"

type MailFilter struct {
	Target          string   `json:"target"`
	Comparator      string   `json:"comparator"`
	Value           string   `json:"value"`
	OrValues        []string `json:"orValues,omitempty"`
	RegexReplaceAll string   `json:"regexReplaceAll,omitempty"`
	Optional        bool     `json:"optional,omitempty"`
}

type MailElement struct {
	Name        string `json:"name"`
	Target      string `json:"target"`
	Selector    string `json:"selector,omitempty"`
	Regex       string `json:"regex,omitempty"`
	RegexSource string `json:"regexSource,omitempty"`
	Formatter   string `json:"formatter,omitempty"`
}

type MailTask struct {
	TemplateID   string        `json:"templateId,omitempty"`
	Group        string        `json:"group,omitempty"`
	Email        string        `json:"email"`
	ReceivedAt   int64         `json:"receivedAt"`
	Timeout      int           `json:"timeout,omitempty"`
	MailFilters  []MailFilter  `json:"mailFilters"`
	MailElements []MailElement `json:"mailElements"`
}

type TaskResult struct {
	ID      string            `json:"id"`
	Status  string            `json:"status"` // "success", "error" or "timeout"
	Results map[string]string `json:"results"`
}

// APIError is any answer other than 200 that was not retried away.
type APIError struct {
	Status     int
	Body       string
	RetryAfter time.Duration // from Retry-After, 0 if absent
}

func (e *APIError) Error() string { return fmt.Sprintf("tasks api %d: %s", e.Status, e.Body) }

type Client struct {
	Key, Group, Host string
	HTTP             *http.Client
	Results          map[string]TaskResult // all pages survive later failures
	expires          map[string]time.Time
	nextPoll         time.Time
	pollGap          time.Duration
}

// Disable connection reuse so net/http cannot transparently replay a poll.
func NewClient(key, group string) *Client {
	return &Client{Key: key, Group: group, Host: host, HTTP: &http.Client{Timeout: 15 * time.Second, Transport: &http.Transport{DisableKeepAlives: true}},
		Results: map[string]TaskResult{}, expires: map[string]time.Time{},
		nextPoll: time.Now().Add(3 * time.Second), pollGap: 3 * time.Second}
}

// Creates retry only explicit 429/503 rejections; never ambiguous transport failures.
func (c *Client) do(ctx context.Context, method, path string, in, out any) error {
	var payload []byte
	if in != nil {
		payload, _ = json.Marshal(in)
	}
	poll := strings.HasPrefix(path, "/api/v1/tasks/completed")
	retryDelay := time.Duration(0)
	for attempt := 0; ; attempt++ {
		delay := retryDelay
		if poll && time.Until(c.nextPoll) > delay {
			delay = time.Until(c.nextPoll)
		}
		timer := time.NewTimer(max(delay, 0))
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
		if err := ctx.Err(); err != nil {
			return err
		}
		// Once sent, a poll runs to HTTP.Timeout, independently of the wait.
		requestCtx := ctx
		if poll {
			requestCtx = context.Background()
		}
		req, err := http.NewRequestWithContext(requestCtx, method, c.Host+path, bytes.NewReader(payload))
		if err != nil {
			return err
		}
		req.Header.Set("Authorization", "Token "+c.Key)
		req.Header.Set("Content-Type", "application/json")
		res, err := c.HTTP.Do(req)
		if poll {
			c.pollGap = 3 * time.Second // consume the one-attempt full-page allowance
			c.nextPoll = time.Now().Add(c.pollGap)
		}
		if err != nil {
			if method != http.MethodGet || attempt >= 5 {
				return err
			}
			retryDelay = time.Duration(1<<attempt) * time.Second
			continue
		}
		data, readErr := io.ReadAll(res.Body)
		res.Body.Close()
		if readErr != nil {
			return readErr
		}
		if res.StatusCode == http.StatusOK {
			if out == nil {
				return nil
			}
			return json.Unmarshal(data, out)
		}
		seconds, _ := strconv.Atoi(res.Header.Get("Retry-After"))
		retryAfter := time.Duration(seconds) * time.Second
		retry := (method == http.MethodGet && res.StatusCode == 408) || res.StatusCode == 503 || (res.StatusCode == 429 && seconds > 0)
		if !retry || attempt >= 5 {
			return &APIError{res.StatusCode, string(data), retryAfter}
		}
		retryDelay = time.Duration(1<<attempt) * time.Second
		if retryAfter > retryDelay {
			retryDelay = retryAfter
		}
	}
}

func (c *Client) Verify() error {
	return c.do(context.Background(), "GET", "/api/v1/user/verify", nil, nil)
}

// CreateTask creates a task in this client's group and returns its id.
func (c *Client) CreateTask(task MailTask) (string, error) {
	task.Group = c.Group
	var created TaskResult
	err := c.do(context.Background(), "POST", "/api/v1/tasks/mail/create", task, &created)
	return created.ID, err
}

// Keep one client per group; serialize waits and polls through it. Creates may run concurrently.
// Completed stores every result, including ids nobody requested.
func (c *Client) Completed() ([]TaskResult, error) {
	return c.completed(context.Background())
}

func (c *Client) completed(ctx context.Context) ([]TaskResult, error) {
	var page struct {
		Tasks []TaskResult `json:"tasks"`
	}
	err := c.do(ctx, "GET", "/api/v1/tasks/completed?group="+url.QueryEscape(c.Group), nil, &page)
	if err != nil {
		return nil, err
	}
	c.pollGap = 3 * time.Second
	if len(page.Tasks) == 100 {
		c.pollGap = time.Second
	}
	c.nextPoll = time.Now().Add(c.pollGap)
	for _, t := range page.Tasks {
		c.Results[t.ID] = t
		c.expires[t.ID] = time.Now().Add(time.Hour)
	}
	c.prune()
	return page.Tasks, nil
}

func (c *Client) prune() {
	for id, expiry := range c.expires {
		if !time.Now().Before(expiry) {
			delete(c.Results, id)
			delete(c.expires, id)
		}
	}
}

func (c *Client) WaitForResults(ids []string, timeoutSeconds int) (map[string]TaskResult, error) {
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds+15)*time.Second)
	defer cancel()
	c.prune()
	collected := func() map[string]TaskResult {
		out := map[string]TaskResult{}
		for _, id := range ids {
			if t, ok := c.Results[id]; ok {
				out[id] = t
			}
		}
		return out
	}
	for len(collected()) < len(ids) && ctx.Err() == nil {
		_, err := c.completed(ctx)
		if ctx.Err() != nil {
			break
		}
		var apiErr *APIError
		if errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized {
			c.nextPoll = time.Now().Add(10 * time.Second)
			continue
		}
		if err != nil {
			return collected(), err
		} // all pages also remain in c.Results
	}
	return collected(), nil // absent ids are lost
}

func main() {
	mail := NewClient(os.Getenv("MAILMAN_TASKS_KEY"), "signup-bot")

	err := mail.Verify()
	var apiErr *APIError
	if errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized {
		fmt.Fprintln(os.Stderr, "The key was rejected, or Mailman isn't open on the user's computer.")
		os.Exit(1)
	}
	if err != nil {
		panic(err)
	}

	const timeout = 120
	id, err := mail.CreateTask(MailTask{
		Email:      "alias@example.com",
		ReceivedAt: time.Now().Unix(),
		Timeout:    timeout,
		MailFilters: []MailFilter{
			{Target: "from", Comparator: "includes", Value: "noreply@example.com"},
			{Target: "subject", Comparator: "includes", Value: "verification code"},
		},
		MailElements: []MailElement{{Name: "otpCode", Target: "body", Regex: "([0-9]{6})"}},
	})
	if err != nil {
		panic(err)
	}

	results, err := mail.WaitForResults([]string{id}, timeout)
	// Handle the partial results before reporting a later polling failure.
	if result, ok := results[id]; ok && result.Status == "success" {
		if code, found := result.Results["otpCode"]; found {
			fmt.Println("code:", code)
		} else {
			fmt.Println("no code found")
		}
	} else {
		fmt.Println("no code:", results[id], "(missing means lost)")
	}

	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		return
	}
	left, _ := mail.Completed()
	fmt.Println("a second poll returns", len(left), "tasks") // 0: delivered once
}
```

### cURL

```sh
# cURL is for trying the API by hand; the other tabs are complete clients.
KEY="$MAILMAN_TASKS_KEY"
HOST="https://tasks.getmailman.app"

# Check the key. -i prints the status line and headers (Retry-After, for example).
curl --connect-timeout 5 --max-time 15 -i "$HOST/api/v1/user/verify" -H "Authorization: Token $KEY"

# Create a task from the JSON request body in task.json.
curl --connect-timeout 5 --max-time 15 -i -X POST "$HOST/api/v1/tasks/mail/create" \
  -H "Authorization: Token $KEY" -H "Content-Type: application/json" -d @task.json

# Collect the results of one group. Repeat every 3 seconds; each result is delivered once.
curl --connect-timeout 5 --max-time 15 -i "$HOST/api/v1/tasks/completed?group=signup-bot" -H "Authorization: Token $KEY"
```

## Integration checklist

Check generated code against every line before you hand it over.

- [ ] The host is exactly `https://tasks.getmailman.app` and TLS verification is on.
- [ ] Every request sends `Authorization: Token <key>`. The key comes from configuration and is never logged or hardcoded.
- [ ] Each task has `email`, `receivedAt` in seconds, at least one filter and one element. The body is under 64 KiB, strings are under 1024 characters, and there are at most 10 filters and 10 elements.
- [ ] `timeout` is set, in seconds, and the client's own monotonic deadline is `timeout` plus about 15 seconds. It caps sleeps and gates new polls/retries, and never aborts a sent poll.
- [ ] The configured polling group overrides any `group` in the task body.
- [ ] Backslashes in JSON regular expressions are doubled.
- [ ] One client and polling loop per group, with serialized waits. Every attempt is 3 seconds apart (1 second only immediately after a full page of 100). Every result is stored as each page arrives, including unrequested ids, for up to 1 hour; later waits and error recovery can read that store.
- [ ] Every HTTP request has its own total 15-second timeout (connect, headers and body). A sent poll may finish up to that long after the wait deadline; its entire page is stored before checking the deadline.
- [ ] A missing result when the wait ends is treated as lost: the code triggers a fresh email and creates a new task.
- [ ] Creates retry only explicit `429` (with `Retry-After`) or `503` rejections; ambiguous transport failures are surfaced. GETs may also retry `408` and transport failures. Every poll attempt waits the maximum of the per-group minimum, `Retry-After`, and backoff. `400` and `413` are not retried. `401` is reported as "key rejected or Mailman not open".
- [ ] Nothing calls the API from browser JavaScript.
- [ ] If the bot supports more than one inbox provider, a provider setting picks the host.

## Not part of the API

Don't invent these: there is no endpoint to cancel a task, fetch one task by id, acknowledge results or list pending tasks; there are no webhooks or callbacks; there is no sandbox or test mode; there is no pagination beyond the 100-task page. Official SDKs are available for Node.js/TypeScript, Python and Go (see above).

Developers: request a free license at https://getmailman.app/developers#developer-license

## Privacy and reliability

- Mail and mailbox passwords stay on the user's computer. Mailman's server relays only the task (address, filters, what to extract) and the extracted values.
- Codes and links pass through Mailman's server in memory only, and are dropped once your bot collects them or after an hour.
- A server restart drops pending tasks and uncollected results. A task id from before a restart never completes, so every wait needs its own deadline (M8).
- Keys work only while the user's Mailman is open and connected. Mailman reconnects by itself after a network drop, and tasks and results already on the server wait for it, so a polling loop should pause and continue rather than give up.

## Existing Inbox Tasks clients

The protocol is the same, so a client that already speaks it needs only its host set to `https://tasks.getmailman.app`. Paths, the `Token ` prefix, the task JSON and the polling pace stay as they are. Clients that hardcode their host need that constant edited, or the origin rewritten in the HTTP transport. Pointing DNS at Mailman fails TLS validation.
