Tasks API

Your bot needs a verification code or a login link from an inbox. Create a mail task over HTTP, poll for the result, and Mailman on the user's computer finds the email.

Quick start Use with AI
Base URLhttps://tasks.getmailman.app
On this pageOverview

Overview

The Tasks API lets a bot wait for an email without touching the mailbox. The bot says which email it expects and what to take out of it, and gets the verification code or login link back. Mailman, running on the user's computer, watches the inbox and does the matching.

  1. Create a task

    Send the address, filters and what to extract to the create endpoint. The answer is a task id.

  2. Mailman finds the email

    The user's Mailman matches the email and extracts the values. Mail and mailbox passwords stay on their computer.

  3. Poll for the result

    Ask the completed endpoint every 3 seconds. Finished tasks come back with their results, such as {"otpCode": "123456"}.

Base URLhttps://tasks.getmailman.app

The same for every user. Hardcode it: users are never asked for an address.

AuthenticationAuthorization: Token <key>

The key is the user's own, copied from Mailman's settings.

FormatJSON over HTTPS

Server to server. There are no CORS headers, so call it from your bot, not from a web page.

EndpointPurposeAnswer
POST /api/v1/tasks/mail/createStart waiting for an email{"id": "…", "status": "pending"}
GET /api/v1/tasks/completed?group=…Collect finished tasks{"tasks": […]}
GET /api/v1/user/verifyCheck a key{}
  • Works with existing Inbox Tasks clients. Same paths, same task JSON, same polling: change the host (details).
  • A key works only while the user's Mailman is open and connected. Otherwise every call answers 401.
  • Each result is delivered once. Poll, keep what you get, and set your own deadline (reliability).

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.

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

Install
npm install mailman-tasks@0.1.0
Verify and get a code
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

Install
python -m pip install mailman-tasks==0.1.0
Verify and get a code
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

Install
go get github.com/chefsonya/mailman-tasks-go@v0.1.0
Verify and get a code
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")
    }
}

Quick start

  1. Get a key from the user. In Mailman they open Settings → Tasks API, turn on Enable Tasks API and press Copy next to the API key. Your bot takes the key as a setting. Building the integration? Request a free developer license to get your own Mailman API key for testing.
  2. Check it. GET /api/v1/user/verify answers {} when the key is valid and the user's Mailman is open.
  3. Create a task. POST /api/v1/tasks/mail/create with the address the email goes to, filters that pick the email, and the values to extract. You get an id back.
  4. Poll. GET /api/v1/tasks/completed?group=… every 3 seconds until the task appears with a status and its results.

Each script below does all four steps. Set MAILMAN_TASKS_KEY to the user's key and run it while their Mailman is open. The address, sender and subject are placeholders: use your site's.

cURL

# Needs bash and curl. The user's key: Mailman → Settings → Tasks API → Copy.
set -e  # stop at the first failing call (-f makes curl fail on HTTP errors such as 401)
KEY="$MAILMAN_TASKS_KEY"
HOST="https://tasks.getmailman.app"
GROUP="signup-bot"
api() { curl --connect-timeout 5 --max-time 15 -fsS -w '\n' -H "Authorization: Token $KEY" -H "Content-Type: application/json" "$@"; }

# 1. Check the key. It answers {} while the user's Mailman is open and connected.
api "$HOST/api/v1/user/verify"

# 2. Create a task: wait for a 6-digit code mailed to alias@example.com.
api -X POST "$HOST/api/v1/tasks/mail/create" -d @- <<EOF
{
  "group": "$GROUP",
  "email": "alias@example.com",
  "receivedAt": $(date +%s),
  "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})" }
  ]
}
EOF
# → {"id":"0f8e7d6c5b4a39281706f5e4d3c2b1a0","status":"pending"}

# 3. Collect by hand after the email arrives. Each result is delivered once.
# Repeat manually no faster than every 3 seconds; keep every result you receive.
api "$HOST/api/v1/tasks/completed?group=$GROUP"
# ? {"tasks":[{"id":"0f8e7d6c5b4a39281706f5e4d3c2b1a0","status":"success","results":{"otpCode":"123456"}}]}
# An empty page means nothing is ready. This manual example does not wait for a task.

Node.js

// Node.js 18+, no dependencies.  Run: MAILMAN_TASKS_KEY=<key> node quickstart.mjs
const HOST = "https://tasks.getmailman.app";
const KEY = process.env.MAILMAN_TASKS_KEY; // the user's key from Mailman's settings
const GROUP = "signup-bot";

async function api(path, body) {
  const res = await fetch(HOST + path, {
    method: body ? "POST" : "GET",
    signal: AbortSignal.timeout(15_000),
    headers: { Authorization: `Token ${KEY}`, "Content-Type": "application/json" },
    body: body && JSON.stringify(body),
  });
  if (!res.ok) {
    console.error(res.status, await res.text());
    process.exit(1);
  }
  return res.json();
}

// 1. Check the key. It answers {} while the user's Mailman is open and connected.
await api("/api/v1/user/verify");

// 2. Create a task: wait for a 6-digit code mailed to alias@example.com.
const { id } = await api("/api/v1/tasks/mail/create", {
  group: GROUP,
  email: "alias@example.com",
  receivedAt: Math.floor(Date.now() / 1000),
  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})" }],
});

// 3. The deadline gates new polls. A sent poll may finish up to 15s later.
const deadline = performance.now() + 135_000;
const results = new Map(); // keep every result, including other tasks
let found = false;
while (performance.now() < deadline) {
  await new Promise((resolve) => setTimeout(resolve, Math.min(3000, deadline - performance.now())));
  if (performance.now() >= deadline) break;
  const { tasks } = await api(`/api/v1/tasks/completed?group=${encodeURIComponent(GROUP)}`);
  for (const task of tasks) results.set(task.id, task); // before checking the wait deadline
  const done = results.get(id);
  if (done) {
    console.log(done.status, done.results); // success { otpCode: '123456' }
    found = true;
    break;
  }
}
if (!found) console.log("task lost: deadline expired");

Python

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

HOST = "https://tasks.getmailman.app"
KEY = os.environ["MAILMAN_TASKS_KEY"]  # the user's key from Mailman's settings
GROUP = "signup-bot"


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


def api(path, body=None):
    status, _, text = fetch_text("GET" if body is None else "POST", HOST + path,
                                None if body is None else json.dumps(body).encode(),
                                key=KEY)
    if status != 200:
        raise SystemExit(f"{status} {text}")
    return json.loads(text)


# 1. Check the key. It answers {} while the user's Mailman is open and connected.
api("/api/v1/user/verify")

# 2. Create a task: wait for a 6-digit code mailed to alias@example.com.
task_id = api("/api/v1/tasks/mail/create", {
    "group": GROUP,
    "email": "alias@example.com",
    "receivedAt": int(time.time()),
    "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})"}],
})["id"]

# 3. The deadline gates new polls. A sent poll may finish up to 15s later.
deadline = time.monotonic() + 135
results = {}  # keep every result, including other tasks
found = False
while time.monotonic() < deadline:
    time.sleep(min(3, deadline - time.monotonic()))
    if time.monotonic() >= deadline:
        break
    tasks = api(f"/api/v1/tasks/completed?group={quote(GROUP, safe='')}")["tasks"]
    results.update((t["id"], t) for t in tasks)  # store before checking the wait deadline
    done = results.get(task_id)
    if done:
        print(done["status"], done.get("results"))  # success {'otpCode': '123456'}
        found = True
        break

if not found:
    print("task lost: deadline expired")

Go

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

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"
)

const (
	host  = "https://tasks.getmailman.app"
	group = "signup-bot"
)

var httpClient = &http.Client{Timeout: 15 * time.Second}

var key = os.Getenv("MAILMAN_TASKS_KEY") // the user's key from Mailman's settings

type result struct {
	ID      string            `json:"id"`
	Status  string            `json:"status"`
	Results map[string]string `json:"results"`
}

// api calls the Tasks API and decodes the JSON answer into out (if out is not nil).
func api(method, path string, body, out any) {
	var payload io.Reader
	if body != nil {
		data, _ := json.Marshal(body)
		payload = bytes.NewReader(data)
	}
	req, _ := http.NewRequest(method, host+path, payload)
	req.Header.Set("Authorization", "Token "+key)
	req.Header.Set("Content-Type", "application/json")
	res, err := httpClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	data, err := io.ReadAll(res.Body)
	if err != nil {
		log.Fatal(err)
	}
	if res.StatusCode != http.StatusOK {
		log.Fatalf("%d %s", res.StatusCode, data)
	}
	if out != nil {
		if err := json.Unmarshal(data, out); err != nil {
			log.Fatal(err)
		}
	}
}

func main() {
	// 1. Check the key. It answers {} while the user's Mailman is open and connected.
	api("GET", "/api/v1/user/verify", nil, nil)

	// 2. Create a task: wait for a 6-digit code mailed to alias@example.com.
	var created result
	api("POST", "/api/v1/tasks/mail/create", map[string]any{
		"group":      group,
		"email":      "alias@example.com",
		"receivedAt": time.Now().Unix(),
		"timeout":    120,
		"mailFilters": []map[string]any{
			{"target": "from", "comparator": "includes", "value": "noreply@example.com"},
			{"target": "subject", "comparator": "includes", "value": "verification code"},
		},
		"mailElements": []map[string]any{
			{"name": "otpCode", "target": "body", "regex": "([0-9]{6})"},
		},
	}, &created)

	// 3. The deadline gates new polls. A sent poll may finish up to 15s later.
	deadline := time.Now().Add(135 * time.Second)
	results := map[string]result{} // keep every result, including other tasks
	for time.Now().Before(deadline) {
		time.Sleep(min(3*time.Second, time.Until(deadline)))
		if !time.Now().Before(deadline) {
			break
		}
		var page struct {
			Tasks []result `json:"tasks"`
		}
		api("GET", "/api/v1/tasks/completed?group="+url.QueryEscape(group), nil, &page)
		for _, t := range page.Tasks {
			results[t.ID] = t
		} // store before checking the wait deadline
		if t, ok := results[created.ID]; ok {
			fmt.Println(t.Status, t.Results) // success map[otpCode:123456]
			return
		}
	}
	fmt.Println("task lost: deadline expired")
}

The Node, Python and Go quick starts use a 135-second monotonic deadline for the 120-second task. That deadline gates new polls and caps sleeps; it never aborts a sent poll. Every HTTP request has its own total 15-second timeout covering connect, headers and body. Store the whole page before checking the wait deadline. A wait may finish up to 15 seconds after its deadline when the network stalls. cURL shows bounded calls to run by hand, with no waiting loop. Use a dedicated group for this one-off example; every polled result is kept, including other tasks; the reference clients below keep those results across waits for shared-group integrations.

Try it yourself. You need a key from a Mailman user, and the email has to arrive. The task above waits up to two minutes for a message to alias@example.com from noreply@example.com with “verification code” in the subject.

Free developer license

Building a bot or tool on the Tasks API? Leave your email and we'll send you a free developer license key so you can test with your own Mailman.

Authentication

Every request carries the user's API key in an Authorization header, with the exact prefix Token (a space follows it):

Header
Authorization: Token 3f9c0a1b…e1a7
  • Where keys come from. The user copies the key from Settings → Tasks API in Mailman. It is 64 hexadecimal characters. The Tasks API needs Mailman 0.1.35 or newer and an active license. If the user regenerates the key, the old one stops working at once and they paste the new one into your bot.
  • One host for everyone. Hardcode https://tasks.getmailman.app. Mailman shows users no address, so your bot asks for the key and nothing else.
  • Treat the key as a password. Whoever holds it can create tasks against that user's mail and read the results while their Mailman is connected. Keep it out of logs, URLs and repositories.
  • It works only while the user's Mailman is open and connected. If it isn't, or the key is wrong, or the header is malformed, the answer is 401 {"error":"invalid user API key"}. The API doesn't say which of these it was.
  • Verify TLS certificates. Never switch certificate checks off to make a request go through.

Create a mail task

POST/api/v1/tasks/mail/create

Describes the email your bot expects and what to take out of it. Mailman answers at once with the task's id and the status pending. The result comes later from the completed endpoint.

Request body
{
  "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
{ "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "pending" }

The body is JSON, at most 64 KiB. A Content-Type header isn't needed. Fields the API doesn't know are ignored, so a misspelled optional field has no effect.

Task fields

FieldTypeRequiredDescription
emailstringyesThe address the email is sent to, such as the address your bot signed up with. Mailman answers from the inbox that owns this address or alias. Only mail sent to this exact address answers the task. Up to 1,024 characters.
receivedAtintegeryesUnix time in seconds (not milliseconds: JavaScript's Date.now() needs dividing by 1000), above 0. Only mail received at or after this moment can answer the task. Use the time just before you trigger the email.
mailFiltersMailFilter[]yes1 to 10 filters that pick the email.
mailElementsMailElement[]yes1 to 10 elements to extract from it.
groupstringnoA label you choose, up to 1,024 characters; missing means "". Results are kept per key and group, and polling asks for one group at a time.
timeoutintegernoSeconds the task may run, counted from creation. Missing, 0 and anything above 3,600 mean 3,600. Setting your own value keeps stuck tasks short.
templateIdstringnoA template the user created in Mailman to choose which inboxes answer. Leave it out unless your users set templates up: an unknown ID fails the task with invalid task template.

Filters

Filters decide which email answers the task. Every filter that isn't optional must match. If every filter is optional, one match is enough.

FieldTypeRequiredDescription
targetstringyesfrom or subject. The sender is compared as Name <address> when the email has a display name, otherwise as the address alone.
comparatorstringyesincludes, excludes, matches or findByRegex. See the table below.
valuestringyesThe text or pattern to compare with. Not empty, up to 1,024 characters.
orValuesstring[]noUp to 10 alternatives. The filter passes if any of value and orValues passes. For excludes, it passes only if none of them appears.
optionalbooleannoMarks the filter as optional, as described above.
regexReplaceAllstringnoA regular expression. Its matches are removed from the target before the comparison.
ComparatorPasses when
includesThe target contains the text, ignoring case.
excludesThe target does not contain the text, ignoring case.
matchesvalue is a regular expression that matches somewhere in the target. It is case-sensitive for subject and case-insensitive for from.
findByRegexThe same as matches.

Elements

Elements say what to take from the matched email. Each one adds a value to results under its name. An element that finds nothing is left out. Several different matches are joined with newlines, and each value is cut at 8,192 characters.

FieldTypeRequiredDescription
namestringyesThe key this value gets in results. Not empty.
targetstringyesfrom (the sender's address), subject or body.
selectorstringbody: this or regexFor body: a CSS selector for the part of the email's HTML to read. A selector that starts with /, ./ or ( is read as XPath.
regexstringnoApplied to the value. Every match counts; if the pattern has a capture group, the first group is the value, otherwise the whole match.
regexSourcestringnoFor a body element with a regex and no selector: pretty (default) runs on the email's readable text, raw on the HTML exactly as sent.
formatterstringnoHow a selector match becomes a value: text (default), html, link or numeric. See the table below.
penetratebooleannoAccepted for compatibility with existing clients. Mailman ignores it.
FormatterValue
textThe visible text of the matched element, with whitespace collapsed.
htmlThe element's HTML.
linkThe href of the element, or of the first link inside it.
numericOnly the digits of the element's text.
Element examples
[
  { "name": "otpCode", "target": "body", "selector": ".otp-code strong", "formatter": "numeric" },
  { "name": "codeInText", "target": "body", "regex": "\\b\\d{6}\\b" },
  { "name": "codeInSubject", "target": "subject", "regex": "\\b\\d{6}\\b" },
  { "name": "loginLink", "target": "body", "selector": "a[href*='/login']", "formatter": "link" },
  { "name": "sender", "target": "from" }
]

In JSON, write each backslash twice. The word-boundary pattern \b is "\\b" in a JSON string. A single \b is a backspace character and silently matches nothing.

How a task is answered

  • 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.
  • Each email answers one task per address and filter set for an hour. An identical task created later won't get an email Mailman already answered with, so ask the site for a fresh email (for example “resend code”) before you recreate a task.
  • Patterns and selectors are compiled on the user's computer, using Perl-style regular expressions (lookahead works). A bad one doesn't produce an HTTP error: the task finishes with an error result whose errorStatus is invalid mail task (result shapes).

Get completed tasks

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

Returns the tasks of one group that have finished, up to 100 per call, oldest first. group must match the group you created the tasks with; leave it out for tasks created without one, and URL-encode it otherwise.

Request
curl -sS "https://tasks.getmailman.app/api/v1/tasks/completed?group=signup-bot" \
  -H "Authorization: Token $KEY"
Response · 200
{
  "tasks": [
    { "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "success", "results": { "otpCode": "123456" } },
    { "id": "a41c9e02d7b3485f9e6c1d2b3a4f5e60", "status": "timeout" }
  ]
}

When nothing has finished, the answer is {"tasks":[]}. A task that is still running never appears here; only success, error and timeout do.

  • Poll every 3 seconds. After a poll that returned a full page of 100, poll again after 1 second. The server doesn't enforce this, but the rate limits assume it.
  • Poll per group, not per task. Create as many tasks as you like in one group and run one polling loop for it, matching results to tasks by id. Two loops on the same group split the results between them.
  • Delivery is once. A result leaves the server in the same step that puts it in the response. If that response is lost, so is the result: there is no acknowledgement and no second chance. Never abort a sent poll because a wait deadline passes. Finish reading it and store every result before checking that deadline; the request still has its own total timeout.
  • Results wait up to an hour. An uncollected result is dropped after 3,600 seconds. Each key and group keeps its newest 50,000.
  • Timeouts are decided by the server. When a task reaches its timeout, it completes as timeout without results. A late answer from Mailman is ignored.

Verify a key

GET/api/v1/user/verify

Answers 200 with {} when the key is valid and the user's Mailman is open and connected. Call it when a user pastes a key into your bot, and before a long run.

Request
curl -sS https://tasks.getmailman.app/api/v1/user/verify -H "Authorization: Token $KEY"

A 401 means the key is wrong or was regenerated, or the user's Mailman isn't open and connected. The API can't tell you which, so show both possibilities to the user (“Check the key, and that Mailman is open”). Verify calls count toward the per-key rate limit like any other.

Result shapes

Create answers with a task in the pending state. Completed tasks come back in the same shape with a final status.

Types
type CreateResponse = { id: string; status: "pending" };

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

type CompletedResponse = { tasks: TaskResult[] };

type ErrorBody = { error: string };  // usual JSON shape; error bodies may be non-JSON
success
{ "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "success", "results": { "otpCode": "123456" } }
timeout
{ "id": "a41c9e02d7b3485f9e6c1d2b3a4f5e60", "status": "timeout" }
error
{
  "id": "7b90c1de24f5486a9b3e0f1a2c3d4e5f",
  "status": "error",
  "results": {
    "errorStatus": "invalid task template",
    "errorMessage": "The task template provided is invalid. Please ensure you make a task template that maps to the templateId provided."
  }
}

A success result always includes a results object, empty ({}) when no element extracted anything. Elements that found nothing are left out; each element that found something has one key. An error result carries errorStatus and errorMessage in results:

Error statusError messageWhen
invalid task templateThe 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>, such as mailElements[0].regex: …The user's Mailman can't compile a selector or regular expression.
internal errorDesktop task capacity reachedThe user's Mailman is already running 10,000 tasks.
internal errorresult discarded: server busyThe result couldn't be kept. Create the task again.
internal errorOther textA local failure on the user's computer while looking up the inbox.

Errors and rate limits

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

StatusMessageMeaning and what to doRetry-After
400invalid task JSONThe body isn't JSON a task can be read from. Fix the request.none
400A validation message, such as email is required, mailFilters has too many entries or mailFilters[0].comparator has invalid value "contains"A field is missing, too long or not an allowed value, and the message names it. Fix the request.none
400invalid group queryThe query string can't be read, for example because group appears twice.none
400May be non-JSONThe Content-Length header is malformed. Fix the header.none
401invalid user API keyThe header is missing or malformed, the key is unknown, or the key's Mailman isn't open and connected. The API doesn't say which. While polling, wait and keep going until your own deadline; at create time, tell the user.none
404 405emptyUnknown path, or the wrong HTTP method. Check both.none
408emptyIf received, the request timed out. A stalled upload is not guaranteed a response. GETs may retry; do not automatically retry creates.none
413request body exceeds 64 KiB or could not be readThe create body is over 64 KiB, or couldn't be read. Make the task smaller.none
429rate limit exceededA rate-limit window is full. Wait the number of seconds in Retry-After, then retry.1 to 60
429pending task limit exceededThe key already has 10,000 tasks waiting. Collect results or let tasks time out. Retrying at once won't help.none
500random generator failedThe server couldn't make a task id. Surface the error; do not automatically retry creates.none
503server busyCreate only: the server is at capacity. Retry after the delay.5
503server shutting downA restart or deploy is under way. Retry shortly. Earlier pending tasks may be lost: use their deadlines, then request fresh emails before replacement.none
  • Honor Retry-After. It is the number of seconds until the current window resets. Never retry sooner.
  • Back off on everything else that is retryable (GET transport failures and 408, or explicit 429/503 rejections): 1, 2, 4, 8, 16 seconds, at most five retries. Every poll attempt, including internal retries, waits the longest of backoff, Retry-After and the per-group minimum: 3 seconds, or 1 second only immediately after a full page of 100.
  • Don't retry 400 or 413. The request is wrong and will stay wrong.
  • Be careful retrying a create. One that timed out or dropped on your side may still have been accepted, and a duplicate would race the first for the same email. The reference clients below retry a create only when the server says it didn't take it (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 other ambiguous failure. GET requests may retry transport failures.

Rate limits

Two fixed 60-second windows apply. Per IP address: 5,000 requests a minute, counting every request from that address including those that fail with 401, so bots behind one shared address share the allowance. Per key: 10,000 requests a minute for create, completed and verify calls, from any address. A group polled every 3 seconds costs 20 requests a minute however many tasks are waiting in it.

Limits

WhatLimit
Create request body64 KiB
Any string in a task1,024 characters
Filters, elements, and orValues per filter10 each
Task timeout3,600 seconds, which is also the default
Tasks returned by one poll100, oldest first
A result value8,192 characters; longer values are cut
Uncollected resultsDropped after 3,600 seconds; the newest 50,000 per key and group are kept
Pending tasks per key10,000
Requests per minute5,000 per IP address and 10,000 per key
Time for one requestUse your own 15-second total timeout; stalled requests may receive no answer

Code samples

Complete clients that use only each language's standard library. They check the key, create a task, poll at the right pace, wait out Retry-After, treat a 401 while polling as “Mailman isn't running right now”, and show that a second poll returns nothing. Use one client per group and serialize its polling calls. Every page is stored immediately in its public results (Results in Go) store, including unrequested ids, for up to one hour. Later waits read this store; it stays accessible after failures. Expired entries are pruned on polls and waits. Wait deadlines only gate new polls/retries and cap sleeps. A sent poll finishes reading and stores every result before checking that deadline; each request keeps its independent total 15-second timeout, so a wait may end up to 15 seconds late. Copy the class into your bot.

cURL

# 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"

Node.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 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 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
}

Production integrations. Prefer the official SDKs for Node.js/TypeScript, Python and Go. Use these reference clients for other languages or to understand the protocol.

Existing Inbox Tasks clients

The Tasks API uses the common Inbox Tasks protocol: the same paths, Token header, task JSON and polling. A bot that already speaks it needs only a new host:

Host
https://tasks.getmailman.app

Nothing else changes. Clients that hardcode their host need that constant edited, or the origin rewritten in their HTTP transport. Pointing DNS at Mailman wouldn't work, because TLS validation would fail, and certificate checks should stay on. If your bot supports more than one provider, choose the host from a provider setting and let the user paste the key that belongs to it.

Use with AI

Give Claude, ChatGPT or Cursor the Tasks API guide and describe your bot. The guide is one self-contained Markdown file with the endpoints, types, rules and reference clients, so the integration it writes follows the API's real rules: polling pace, delivery once, backoff.

A prompt to paste

Need your own key for testing? Request a free developer license.

Fill in the brackets. The prompt links to the guide. If your assistant can't open links, copy the guide and paste it into the chat first.

Integrate Mailman's hosted Tasks API into my bot so it can get verification codes and login links from a user's inbox. Read this guide first and follow its MUST and SHOULD rules exactly: https://getmailman.app/tasks-api.md My bot is written in [language]. It needs to [what it waits for, for example "enter the 6-digit code that example.com emails after sign-up"]. Hardcode https://tasks.getmailman.app as the host, read the user's API key from my bot's settings, and prefer the guide's official SDK for Node.js/TypeScript, Python or Go; use a reference client otherwise. When you finish, check the code against the guide's integration checklist.
Open tasks-api.md

Privacy and reliability

  • Mail stays on the user's computer. Mailman reads the inbox there. 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.

The same design means some things can go wrong, and your bot should expect them:

  • A server restart drops pending work. Tasks that hadn't finished, and results nobody had collected yet, are gone, and an old task id will never complete. Give every wait its own deadline (the task's timeout plus about 15 seconds, measured with a monotonic clock to cap sleeps and gate new polls/retries), treat a missing result as lost, and create the task again. If you recreate an identical task, ask the site for a fresh email first: Mailman uses each email once per address and filter set for an hour, so the new task can't take an email that already answered the old one.
  • Keys work only while the user's Mailman is open and connected. Otherwise every call answers 401. 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.
  • A response can be lost in transit. Delivery is once, so a result in a lost response is gone. Never 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. Its independent total request timeout still applies; a wait may end up to 15 seconds late. A response lost even with this precaution requires a fresh email and task.

Want Mailman for your inboxes?

The Tasks API comes with Mailman, which is in an invite-only beta. Spots open in batches: leave your email and we'll let you know when it's your turn.

Join the waitlist