Schedule email API: how to schedule, move and cancel an email before it goes
Published Updated 17 min readBy the Rasket team
Written for developers and AI automation builders

To schedule an email through an API, add a scheduled_at time to the same POST /emails request you would send immediately. Rasket stores the message and sends it at that instant, from 1 minute to 30 days ahead. Until it goes, PATCH /emails/{id} moves it and POST /emails/{id}/cancel stops it.
Four things that catch people out
A key with sending_access can schedule but cannot read, move or cancel; the job that cancels needs full_access. Over OAuth and MCP the cancel sits under the read scope, emails:read, so an agent you let read mail can also stop it. Quota is spent on the day the email is accepted, not the day it goes, and a cancel gives it back. And a day phrase such as tomorrow at 9am is read as UTC, because Rasket has no account time zone.
The three calls: schedule, move, cancel
A trial ends tomorrow, and the customer should hear about it at 09:00 on their own clock. Then they extend the trial by a day, so the reminder moves. Then they upgrade, so it must not go at all. From the shell that is three requests. The responses below, and the Node output further down, are from runs just after 01:00 Berlin time on 18 October 2026, so “tomorrow” is the 19th. The shell test has a key of its own, because a key reused with a different body is refused:
API="${RASKET_API_URL:-https://api.rasket.com}"
AUTH=(-H "Authorization: Bearer $RASKET_API_KEY" -H "User-Agent: acme-billing/1.0")
# 1. Schedule: the ordinary send request, plus scheduled_at
curl -X POST "$API/emails" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: shell-test-cus_1042" \
-d '{
"from": "Acme <billing@acme.example>",
"to": ["ronald.williams@example.com"],
"subject": "Your trial ends tomorrow",
"html": "<p>Your trial ends tomorrow.</p>",
"scheduled_at": "2026-10-19T07:05:00Z"
}'
# 200 {"id":"01a14c21-24d0-7b4e-94ad-5b45f52c027c"}
EMAIL_ID=01a14c21-24d0-7b4e-94ad-5b45f52c027c
# 2. Move it a day, to 09:05 Berlin on the 20th. ("in 1 day" would count from now.)
curl -X PATCH "$API/emails/$EMAIL_ID" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "scheduled_at": "2026-10-20T07:05:00Z" }'
# 200 {"object":"email","id":"01a14c21-24d0-7b4e-94ad-5b45f52c027c"}
# GET /emails/$EMAIL_ID: "last_event":"scheduled","scheduled_at":"2026-10-20T07:05:00.000Z"
# 3. Cancel it, then try again
curl -X POST "$API/emails/$EMAIL_ID/cancel" "${AUTH[@]}"
# 200 {"object":"email","id":"01a14c21-24d0-7b4e-94ad-5b45f52c027c"}, and GET shows "last_event":"canceled"
curl -X POST "$API/emails/$EMAIL_ID/cancel" "${AUTH[@]}"
# 400 {"statusCode":400,"name":"validation_error","message":"Email is not scheduled."}A PATCH reads scheduled_at and nothing else. A subject in the same body is dropped without an error and the answer is still 200, so to change the content, cancel and schedule a new email. A relative phrase such as in 1 day counts from the PATCH, not from the old time, so moving a reminder by a day takes an explicit instant.
A trial reminder at 09:00 on the customer's clock
The nightly job does the same three calls from code. The one hard part is the instant: 09:00 in Berlin is 07:00 UTC until the clocks go back on 25 October and 08:00 UTC after, and a plain Date knows only the offset of the machine it runs on. zone.ts asks Intl.DateTimeFormat for the customer’s offset instead. Temporal.ZonedDateTime does this in one call, and Node 26, LTS from October 2026, turns it on by default. Node 24 has it only behind the --harmony-temporal flag, and MDN still marks it limited availability.
import { localTime } from "./zone.ts";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const RETRY = new Set(["rate_limit_exceeded", "resource_locked", "concurrent_idempotent_requests"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
type Answer = { id?: string; name?: string; message?: string };
// One request, retried on a lost answer, a busy answer or a 5xx, always with the same key.
// A quota 429 is thrown: waiting will not fix it.
async function rasket(method: string, path: string, body?: object, key?: string): Promise<Answer> {
for (let attempt = 1; ; attempt += 1) {
let response: Response;
try {
response = await fetch(`${API}${path}`, {
method,
headers: {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"content-type": "application/json",
"user-agent": "acme-billing/1.0",
...(key ? { "idempotency-key": key } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
// No answer (refused, reset, timed out): the request may or may not have landed.
const lost = error instanceof Error && ["TypeError", "TimeoutError"].includes(error.name);
if (!lost || attempt === 5) throw error;
await sleep(500 * 2 ** attempt);
continue;
}
const answer: Answer = await response.json().catch(() => ({ name: "not_json" }));
if (response.ok) return answer;
if (attempt < 5 && (response.status >= 500 || RETRY.has(answer.name ?? ""))) {
// Rasket sends retry-after in whole seconds, never as a date.
const wait = Number(response.headers.get("retry-after")) * 1000 || 500 * 2 ** attempt;
await sleep(wait * (1 + Math.random() / 2)); // jitter, so parallel workers do not retry in step
continue;
}
throw new Error(`${response.status} ${answer.name}: ${answer.message}`, { cause: answer });
}
}
// The customer, from your own database.
const customer = { id: "cus_1042", email: "ronald.williams@example.com", zone: "Europe/Berlin" };
// 09:00 tomorrow on their clock, plus 0-19 minutes from the id, so a cohort spreads over
// 09:00-09:19 and a retried job builds the same body.
const minute = [...customer.id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % 20;
const sendAt = localTime(customer.zone, 1, 9, minute);
const email = await rasket(
"POST",
"/emails",
{
from: "Acme <billing@acme.example>",
to: [customer.email],
subject: "Your trial ends tomorrow",
html: "<p>Your trial ends tomorrow. Your data stays where it is.</p>",
scheduled_at: sendAt.toISOString(),
},
`trial-reminder-${customer.id}-${sendAt.toISOString().slice(0, 10)}`, // who, and which day
);
console.log("scheduled", email.id, "for", sendAt.toISOString());
// A 400 on a PATCH or a cancel means the email has already gone or been canceled.
const gone = (error: unknown) => (error as { cause?: Answer }).cause?.name === "validation_error";
// The customer extended the trial by a day: move the reminder with it.
const later = localTime(customer.zone, 2, 9, minute);
try {
await rasket("PATCH", `/emails/${email.id}`, { scheduled_at: later.toISOString() });
console.log("moved to", later.toISOString());
} catch (error) {
if (!gone(error)) throw error;
console.log("too late to move:", (error as Error).message);
}
// They upgraded: the reminder is no longer wanted.
try {
await rasket("POST", `/emails/${email.id}/cancel`);
console.log("canceled");
} catch (error) {
if (!gone(error)) throw error;
console.log("too late to cancel:", (error as Error).message);
}const DAY = 86_400_000;
// The zone's UTC offset at an instant, in milliseconds ("GMT+02:00" -> 7200000).
function offsetMs(instant: number, timeZone: string): number {
const label =
new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" })
.formatToParts(new Date(instant))
.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
const match = /GMT([+-])(\d{2}):(\d{2})/.exec(label);
if (!match) return 0; // "GMT" alone means UTC
const minutes = Number(match[2]) * 60 + Number(match[3]);
return (match[1] === "-" ? -1 : 1) * minutes * 60_000;
}
// The instant at which timeZone's clock reads hour:minute, `days` days after today there.
// Like zoneinfo and Temporal's default: a skipped time moves forward, a repeated one takes the first.
export function localTime(timeZone: string, days: number, hour: number, minute: number): Date {
const today = new Intl.DateTimeFormat("en-CA", { timeZone }).format(new Date()); // "2026-10-18"
const [y = 0, m = 1, d = 1] = today.split("-").map(Number);
const wall = Date.UTC(y, m - 1, d + days, hour, minute);
const before = wall - offsetMs(wall - DAY, timeZone); // the offset in force a day earlier
const after = wall - offsetMs(wall + DAY, timeZone); // and a day later
const real = [before, after].filter((t) => t + offsetMs(t, timeZone) === wall);
return new Date(real.length > 0 ? Math.min(...real) : before);
}$ node schedule.ts
scheduled 01a14c31-3959-78b2-abf1-f843425a59c1 for 2026-10-19T07:05:00.000Z
moved to 2026-10-20T07:05:00.000Z
canceledSave both files side by side, next to a package.json holding { "type": "module" }, and run node schedule.ts on Node 24, which strips the types. Stripping does not check them. For that, run npm i -D typescript @types/node, then npx tsc --noEmit --strict --module nodenext --allowImportingTsExtensions --types node *.ts, which passes on both files with TypeScript 7.0.2. The parts worth copying:
- The minute is spread. A thousand reminders set for 09:00 sharp all reach the same receiving servers at once. The minute derived from the customer id spreads them over 09:00 to 09:19, and because it comes from the id, a retried job builds the same body.
- The key names the reminder, not the run. With the idempotency key
trial-reminder-cus_1042-2026-10-19, a retried job gets the first id back instead of scheduling a second reminder. The day is in the key because the body depends on it: our first draft keyed on the customer alone, and a rerun after midnight in Berlin built a newscheduled_atunder the old key and got409 invalid_idempotent_request. Run the whole script twice in one night and the secondPOSThands back the first email, already canceled. ItsPATCHthen answers400, whichgone()reads as “too late”: “Email is not scheduled.” is the only400these two routes give a well-formed request. In your own job, take the day from the trial’s end date in your table rather than the clock, so a rerun after midnight still names the same reminder. The key is kept for 24 hours from the request, not until the send time, so check your table for a stored id before scheduling again days later. - The retry waits only where waiting helps.
rasket()retries a lost answer, a5xxand the three busy answers, always with the same idempotency key, afterretry-afteror a doubling wait with jitter, and throws everything else. Run against a stub that dropped the first connection, it sent thePOSTagain under the same key; against one that answered429,503, then409, it retried each and finished; a429 daily_quota_exceededwas thrown at once. The rules are those of the retry loop in the idempotency post, and the rate-limit post explains the waits.
In Python, zoneinfo (3.9 and later; on Windows, pip install tzdata first) does the zone work in four lines. isoformat() writes +00:00 instead of Z, and the API takes both:
from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
customer = {"id": "cus_1042", "email": "ronald.williams@example.com", "zone": "Europe/Berlin"}
minute = sum(map(ord, customer["id"])) % 20 # the same 0-19 minutes as the Node job
zone = ZoneInfo(customer["zone"])
tomorrow = datetime.now(zone).date() + timedelta(days=1)
local = datetime.combine(tomorrow, time(9, minute), tzinfo=zone)
send_at = local.astimezone(timezone.utc).isoformat() # "2026-10-19T07:05:00+00:00"
later = (local + timedelta(days=1)).astimezone(timezone.utc).isoformat() # for the PATCHThe requests are the curl calls above. For a standard-library request loop, take post_batch from the rate-limit post’s Python sample: every message in a batch can carry its own scheduled_at, so it schedules a cohort as it is. For the PATCH and cancel, add resource_locked to its RETRY set.
What each answer means, and what to retry
What decides the answer to a PATCH or a cancel is the moment Rasket’s send worker claims the email, at or just after scheduled_at:
| The email is | PATCH and cancel answer | What to do |
|---|---|---|
Waiting (last_event: scheduled) | 200 | Nothing. It moved, or it will not be sent. |
| Being claimed this second | 409 resource_locked | Retry once after a moment. The retry is normally the 400. |
| Sent, failed, already canceled, or never scheduled | 400 validation_error, “Email is not scheduled.” | Stop. A sent message cannot be recalled. |
| Unknown, or another team's | 404 not_found | Check the id you stored. |
The OpenAPI document’s route descriptions state the 400 and 409 cases and the quota refund. Retry a POST only with the same idempotency key. Retry 429 rate_limit_exceeded after its retry-after, and a 5xx a capped number of times with a growing wait. Never retry a 422, a 400, a quota 429 or 409 invalid_idempotent_request: the same body gets the same answer. A sending-only key gets 401 restricted_api_key, not 403, so alert on the error name.
One case needs a second look. If a cancel’s 200 is lost to a 5xx and the retry answers 400, the email was canceled, not sent. GET /emails/{id} tells them apart: last_event reads canceled or sent. A successful cancel also emits an email.canceled event to any webhook subscribed to it.
What scheduled_at accepts
- An ISO 8601 instant with a zone, such as
2026-10-18T07:00:00Zor2026-10-18T09:00:00+02:00, the same instant written two ways. Use it from code. - A relative phrase, such as
in 2 hoursorin 3 days: now plus that duration. No clock is involved, so it is safe in any zone. - A day phrase, such as
tomorrow at 9amornext tuesday 9am, read as UTC, with 09:00 when no time is given. For a customer in Berlin that is 10:00 or 11:00, and in New York four or five in the morning. Do not send one from code or from an agent.
The window is the same for every form: at least 1 minute and at most 30 days after the request, as the emails reference states. A bad time is refused when the request arrives, not when the send time comes:
# "scheduled_at": "2026-10-01T09:00:00Z", sent on 17 October
422 {"statusCode":422,"name":"invalid_parameter",
"message":"scheduled_at: scheduled_at must be at least 1 minute in the future."}
# "scheduled_at": "in 31 days"
422 {"statusCode":422,"name":"invalid_parameter",
"message":"scheduled_at: scheduled_at may not be more than 30 days in the future."}
# "scheduled_at": "2026-10-18T09:00:00", no zone
422 {"statusCode":422,"name":"invalid_parameter",
"message":"scheduled_at: scheduled_at must be an ISO 8601 datetime such as
2026-09-08T20:41:00.000Z, or a phrase such as \"in 2 hours\", \"tomorrow at 9am\"
or \"next tuesday 9am\", read as UTC."}Log a 422 invalid_parameter and do not retry it. If the job ran late and the reminder still matters, send it now without scheduled_at. A timestamp with no zone is refused rather than guessed at, which is what RFC 3339 asks of internet timestamps: its section 4.4 says an unqualified local time “will fail in approximately 23/24 of the globe”.
Daylight saving, and why 09:00
Store each customer’s IANA zone name, such as Europe/Berlin, never the offset you saw at sign-up. Berlin is +02:00 in September and +01:00 in November, so a stored offset lands every reminder after a clock change an hour off. Compute the instant on the night you schedule, so a zone rule that changed since sign-up is the rule you use. The tz database’s latest release, as of 17 October 2026, is 2026e from 29 September.
Twice a year a wall-clock time is not a valid instant. When clocks go forward an hour is skipped, and when they go back an hour is repeated:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
berlin = ZoneInfo("Europe/Berlin")
# 29 March 2026: clocks jump from 02:00 to 03:00, so 02:30 never happens.
print(datetime(2026, 3, 29, 2, 30, tzinfo=berlin).astimezone(timezone.utc))
# 2026-03-29 01:30:00+00:00 (03:30 on the new clock)
# 25 October 2026: clocks fall back from 03:00 to 02:00, so 02:30 happens twice.
print(datetime(2026, 10, 25, 2, 30, tzinfo=berlin, fold=0).astimezone(timezone.utc))
# 2026-10-25 00:30:00+00:00 (the first 02:30)
print(datetime(2026, 10, 25, 2, 30, tzinfo=berlin, fold=1).astimezone(timezone.utc))
# 2026-10-25 01:30:00+00:00 (the second, an hour later)zoneinfo follows PEP 495: a skipped time takes the offset from before the change, so it goes an hour later than the clock said, and fold picks the first or the second repeated 02:30. Temporal’s default and zone.ts make the same choices. A reminder at 09:00 never meets the question. We stepped every zone zoneinfo knows through 2026 and 2027 in 15-minute steps, and every clock change fell between 21:00 and 04:00 local time. Not all fall at 02:00: Lord Howe Island shifts by 30 minutes, Chatham changes at 02:45 standard time, and Cairo, Beirut and Santiago at midnight, so 00:00 is not safe everywhere.
Letting an AI agent schedule: a narrow MCP tool
Rasket’s MCP server can cancel a scheduled email but cannot schedule one. Its send_email tool sends now, and its input schema has no scheduled_at. cancel_email sits under emails:read, on the grounds that a cancel only ever prevents mail, and it is in the send toolset. So a connection made with ?toolset=send, which asks for emails:read as well as emails:send, can stop every scheduled reminder you have. When the agent only sends, request the OAuth scope emails:send alone, as that post’s sign-in.mjs does. Campaigns are different: send_broadcast takes scheduled_at, behind the preview and confirm step.
To let an agent schedule a single email, give it a narrow tool of your own instead of the API. This one is a local MCP server over stdio, built with the MCP TypeScript SDK (1.30) and zod 4:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { trialEndingOn } from "./db.ts"; // yours: null unless this trial really ends that day
import { localTime } from "./zone.ts";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const server = new McpServer({ name: "acme-billing", version: "1.0.0" });
server.registerTool(
"schedule_trial_reminder",
{
description: "Schedule the trial-ending reminder for one customer, at 09:00 their time.",
inputSchema: { customer_id: z.string(), days_ahead: z.number().int().min(1).max(29) },
},
async ({ customer_id, days_ahead }) => {
const customer = await trialEndingOn(customer_id, days_ahead);
if (!customer) return { isError: true, content: [{ type: "text", text: "No trial ends then." }] };
const minute = [...customer_id].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % 20;
const sendAt = localTime(customer.zone, days_ahead, 9, minute); // 09:00-09:19, as above
const response = await fetch(`${API}/emails`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.RASKET_SENDING_KEY}`, // sending_access, never in a prompt
"content-type": "application/json",
"idempotency-key": `trial-reminder-${customer_id}-${sendAt.toISOString().slice(0, 10)}`,
},
body: JSON.stringify({
from: "Acme <billing@acme.example>",
to: [customer.email],
subject: "Your trial ends soon",
html: "<p>Your trial ends soon. Your data stays where it is.</p>",
scheduled_at: sendAt.toISOString(),
}),
});
const answer = await response.json().catch(() => ({}));
const text = response.ok
? `Scheduled ${answer.id} for ${sendAt.toISOString()}`
: `${response.status} ${answer.name ?? "error"}: ${answer.message ?? "no details"}`;
return { isError: !response.ok, content: [{ type: "text", text }] };
},
);
await server.connect(new StdioServerTransport());Register it with the client as a local command. For Claude Desktop that is one entry in claude_desktop_config.json, with the tool’s directory holding the same package.json and the SDK installed (npm i @modelcontextprotocol/sdk zod):
{
"mcpServers": {
"acme-billing": {
"command": "node",
"args": ["/path/to/acme-billing/tools.ts"],
"env": { "RASKET_SENDING_KEY": "rk_your_sending_access_key" }
}
}
}- The model chooses who and when, never what. The address, the zone, the sender and the words come from your code.
trialEndingOnis your own lookup, and it refuses a customer whose trial does not end that day, so a confused or injected prompt cannot mail an arbitrary customer. - The key stays with the tool. A
sending_accesskey from the environment can schedule and nothing else, and it never enters the model’s context. - Its limits are the API’s.
days_aheadstops at 29, because 09:00 thirty days out can fall past the window. The key names the customer and the day, so a repeated call returns the same id: called twice against the API, it answered with one email. - Cancel is a second tool, not a wider key. A
cancel_trial_reminder(customer_id)beside it looks the stored id up in your own table and callsPOST /emails/{id}/cancelwith a separatefull_accesskey, so the model never handles an email id.
No API key permission sits between sending_access and full_access; the third, store_access, only pushes store events. Letting an AI agent send email covers what the MCP server enforces and the approval gate for anything the agent writes itself. n8n, Zapier and Make each have schedule, reschedule and cancel steps, documented under the integrations docs.
Large cohorts, and what a schedule will not do
Every API call, a GET or a cancel as much as a send, takes a token from one bucket per team that refills at 10 a second. Scheduling is cheap: each element of POST /emails/batch can carry its own scheduled_at, so 10,000 reminders are 100 batches of 100, about 10 seconds at the limit. Canceling is not: no call cancels by tag or filter, so canceling the same cohort is 10,000 requests, about 17 minutes at the full bucket, and live sends share it. Pace a bulk cancel well under the limit, as the rate-limit post shows.
A scheduled email is never sent early: the worker will not claim a job whose time has not come, and a rescheduled one is held to its new time. From then on it goes out at your team’s sending rate, which is set by your sending tier and is separate from the API limit, so ten thousand reminders due at 09:00 leave over a period, not in one second. Spreading the minute smooths one morning. It does not warm up a new domain, which takes weeks of rising volume, as the warm-up guide explains.
GET /emails?status=scheduled lists what is still waiting, with each scheduled_at. The email.scheduled and email.canceled webhooks report the changes as they happen, and Email webhooks explained covers verifying them.
A scheduled send holds one message for one instant, and it evaluates no rule of yours when the time comes. It does not repeat, so a weekly digest belongs in your own cron. It does not reach past 30 days, so an anniversary waits in your table until it is inside the window. It does not know a customer bought, so an abandoned-checkout reminder is better as an automation that lists store:order.placed in its exit_events, which ends the run before the email. And it does not read a recipient’s zone, so a campaign at local 09:00 is one scheduled broadcast per zone, as the newsletter API post shows. Once a message has been sent it is with the recipient’s mailbox provider, and no API can take it back. Put any decision that might withdraw it before its send time.
Frequently asked questions
Is there a bulk cancel for a whole cohort of scheduled emails?
No single call cancels by tag or filter. Tag the emails when you schedule them, list the cohort with GET /emails?status=scheduled&tags=cohort:trial-2026-10-18, then cancel each id in turn, paced under the API's rate limit. A 400 on one of them only means that email had already gone.
What happens if my domain loses verification, or a recipient's address goes bad, before the send time?
Both are checked again when the send worker claims the email. If the sending domain is no longer verified, the email fails with an email.failed event. A recipient added to the suppression list since, by a bounce, a complaint or a manual block, is dropped with an email.suppressed event. An unsubscribe does not stop an API reminder: it blocks campaigns and automations only.
Can I see which API key cancelled a scheduled email?
Yes. Every API request is in GET /logs with its method, endpoint, response status, user agent and api_key_id. Pass the email id as search, as in GET /logs?search=01a148e1-a16d-7cf9-aa19-60d627d1800a, and the cancel shows up as POST /emails/{email_id}/cancel with the id of the key that made it.
Does the email.scheduled webhook say when the email will go out?
No. Its data carries the email id and the recipients, not the send time. To show a customer when a message is due, read scheduled_at from GET /emails/{id}, or keep the instant you sent in your own table, which also saves a request per event.
Can a scheduled email be pushed out further than 30 days?
Only in steps. Each PATCH is checked against a window counted from the moment of that PATCH, so an email can be moved again and again, up to thirty days from each request. For a date months away, keeping it in your own table and scheduling it once it comes inside the window is simpler.
Sources
- RFC 3339: Date and Time on the Internet: Timestamps — IETF, read 2026-10-17
- Time Zone Database (release 2026e, 2026-09-29) — IANA, read 2026-10-17
- zoneinfo — IANA time zone support — Python Software Foundation, read 2026-10-17
- PEP 495: Local Time Disambiguation — Python Software Foundation, read 2026-10-17
- Temporal.ZonedDateTime — MDN Web Docs, read 2026-10-17
- Emails API reference: send, reschedule and cancel — Rasket, read 2026-10-17
- MCP server: tools and scopes — Rasket, read 2026-10-17
- Modules: TypeScript (type stripping) — Node.js, read 2026-10-17
- MCP TypeScript SDK: server tools — Model Context Protocol, read 2026-10-17
Related
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- How to warm up a new domain or IP address — How to warm up a new domain or IP: what Gmail, Microsoft, Yahoo and M3AAWG publish, schedules from 300 contacts to 50,000 a day, and when to slow down.
- Idempotent email sends: retry without sending twice — How to make email sends idempotent and stop duplicate emails: choosing an Idempotency-Key, a tested retry loop, keys for agents, and what each repeat returns.
- Email webhooks explained: events and signatures — Email webhooks explained: the delivery events, the payload, HMAC signature verification, retries, and complete tested receivers in Node and Python.
- Automations — Workflows that run per contact: the graph, its versions, and every run.
- Let an AI agent send email with MCP — Connect an agent to an MCP email server: the OAuth sign-in for your own loop, the send_email call, prompt injection, and a person approving every send.
- Email API rate limits: headers and backoff — Email API rate limits, measured: the ratelimit headers Rasket sends, what a 429 looks like, and Node and Python backoff that retries without sending twice.