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.
Create a task
Send the address, filters and what to extract to the create endpoint. The answer is a task
id.Mailman finds the email
The user's Mailman matches the email and extracts the values. Mail and mailbox passwords stay on their computer.
Poll for the result
Ask the completed endpoint every 3 seconds. Finished tasks come back with their results, such as
{"otpCode": "123456"}.
https://tasks.getmailman.appThe same for every user. Hardcode it: users are never asked for an address.
Authorization: Token <key>The key is the user's own, copied from Mailman's settings.
JSON over HTTPSServer to server. There are no CORS headers, so call it from your bot, not from a web page.
| Endpoint | Purpose | Answer |
|---|---|---|
POST /api/v1/tasks/mail/create | Start waiting for an email | {"id": "…", "status": "pending"} |
GET /api/v1/tasks/completed?group=… | Collect finished tasks | {"tasks": […]} |
GET /api/v1/user/verify | Check 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
Package and API docs · Source on GitHub · 0.1.0 · MIT
npm install mailman-tasks@0.1.0import { 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 · Source on GitHub · 0.1.0 · MIT
python -m pip install mailman-tasks==0.1.0import 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 · Source on GitHub · 0.1.0 · MIT
go get github.com/chefsonya/mailman-tasks-go@v0.1.0package 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
- 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.
- Check it.
GET /api/v1/user/verifyanswers{}when the key is valid and the user's Mailman is open. - Create a task.
POST /api/v1/tasks/mail/createwith the address the email goes to, filters that pick the email, and the values to extract. You get anidback. - Poll.
GET /api/v1/tasks/completed?group=…every 3 seconds until the task appears with astatusand itsresults.
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):
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
/api/v1/tasks/mail/createDescribes 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.
{
"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})" }
]
}{ "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
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | The 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. |
receivedAt | integer | yes | Unix 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. |
mailFilters | MailFilter[] | yes | 1 to 10 filters that pick the email. |
mailElements | MailElement[] | yes | 1 to 10 elements to extract from it. |
group | string | no | A 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. |
timeout | integer | no | Seconds 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. |
templateId | string | no | A 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.
| Field | Type | Required | Description |
|---|---|---|---|
target | string | yes | from or subject. The sender is compared as Name <address> when the email has a display name, otherwise as the address alone. |
comparator | string | yes | includes, excludes, matches or findByRegex. See the table below. |
value | string | yes | The text or pattern to compare with. Not empty, up to 1,024 characters. |
orValues | string[] | no | Up to 10 alternatives. The filter passes if any of value and orValues passes. For excludes, it passes only if none of them appears. |
optional | boolean | no | Marks the filter as optional, as described above. |
regexReplaceAll | string | no | A regular expression. Its matches are removed from the target before the comparison. |
| Comparator | Passes when |
|---|---|
includes | The target contains the text, ignoring case. |
excludes | The target does not contain the text, ignoring case. |
matches | value is a regular expression that matches somewhere in the target. It is case-sensitive for subject and case-insensitive for from. |
findByRegex | The 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.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The key this value gets in results. Not empty. |
target | string | yes | from (the sender's address), subject or body. |
selector | string | body: this or regex | For body: a CSS selector for the part of the email's HTML to read. A selector that starts with /, ./ or ( is read as XPath. |
regex | string | no | Applied to the value. Every match counts; if the pattern has a capture group, the first group is the value, otherwise the whole match. |
regexSource | string | no | For 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. |
formatter | string | no | How a selector match becomes a value: text (default), html, link or numeric. See the table below. |
penetrate | boolean | no | Accepted for compatibility with existing clients. Mailman ignores it. |
| Formatter | Value |
|---|---|
text | The visible text of the matched element, with whitespace collapsed. |
html | The element's HTML. |
link | The href of the element, or of the first link inside it. |
numeric | Only the digits of the element's text. |
[
{ "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
receivedAtthat 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
errorresult whoseerrorStatusisinvalid mail task(result shapes).
Get completed tasks
/api/v1/tasks/completed?group=signup-botReturns 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.
curl -sS "https://tasks.getmailman.app/api/v1/tasks/completed?group=signup-bot" \
-H "Authorization: Token $KEY"{
"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 astimeoutwithoutresults. A late answer from Mailman is ignored.
Verify a key
/api/v1/user/verifyAnswers 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.
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.
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{ "id": "0f8e7d6c5b4a39281706f5e4d3c2b1a0", "status": "success", "results": { "otpCode": "123456" } }{ "id": "a41c9e02d7b3485f9e6c1d2b3a4f5e60", "status": "timeout" }{
"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 status | Error message | 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>, such as 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 10,000 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. |
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.
| Status | Message | Meaning and what to do | Retry-After |
|---|---|---|---|
| 400 | invalid task JSON | The body isn't JSON a task can be read from. Fix the request. | none |
| 400 | A 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 |
| 400 | invalid group query | The query string can't be read, for example because group appears twice. | none |
| 400 | May be non-JSON | The Content-Length header is malformed. Fix the header. | none |
| 401 | invalid user API key | The 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 405 | empty | Unknown path, or the wrong HTTP method. Check both. | none |
| 408 | empty | If received, the request timed out. A stalled upload is not guaranteed a response. GETs may retry; do not automatically retry creates. | none |
| 413 | request body exceeds 64 KiB or could not be read | The create body is over 64 KiB, or couldn't be read. Make the task smaller. | none |
| 429 | rate limit exceeded | A rate-limit window is full. Wait the number of seconds in Retry-After, then retry. | 1 to 60 |
| 429 | pending task limit exceeded | The key already has 10,000 tasks waiting. Collect results or let tasks time out. Retrying at once won't help. | none |
| 500 | random generator failed | The server couldn't make a task id. Surface the error; do not automatically retry creates. | none |
| 503 | server busy | Create only: the server is at capacity. Retry after the delay. | 5 |
| 503 | server shutting down | A 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 explicit429/503rejections): 1, 2, 4, 8, 16 seconds, at most five retries. Every poll attempt, including internal retries, waits the longest of backoff,Retry-Afterand the per-group minimum: 3 seconds, or 1 second only immediately after a full page of 100. - Don't retry
400or413. 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 (
429withRetry-After, or503). 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
| What | Limit |
|---|---|
| Create request body | 64 KiB |
| Any string in a task | 1,024 characters |
Filters, elements, and orValues per filter | 10 each |
Task timeout | 3,600 seconds, which is also the default |
| Tasks returned by one poll | 100, oldest first |
| A result value | 8,192 characters; longer values are cut |
| Uncollected results | Dropped after 3,600 seconds; the newest 50,000 per key and group are kept |
| Pending tasks per key | 10,000 |
| Requests per minute | 5,000 per IP address and 10,000 per key |
| Time for one request | Use 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 oncePython
# 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 onceGo
// 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:
https://tasks.getmailman.appNothing 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.
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
timeoutplus 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.