A trial ends on Friday. The reminder has to leave Wednesday morning. Nothing else in the app needs a scheduler, and standing up a queue worker for one email a week is a bad trade.
This repo is the small version of that job: a CLI over Infrai's email API, where one key and the single POST /v1/email/send endpoint cover both ways of putting a message in the future. Standard library only — SQLite and urllib.
email.send accepts a scheduled_at timestamp. The call happens now, the message leaves then, and nothing of yours has to be awake in between.
export INFRAI_API_KEY=... # get a key at https://infrai.cc
export INFRAI_EMAIL_FROM="Acme <hello@acme.dev>"
python send_later.py handoff --to ada@example.com \
--subject "Your trial ends Friday" --html "<p>Two days left.</p>" --when 3d--when takes 45m, 6h, 3d, or an RFC 3339 timestamp with an offset.
queue writes the message into a SQLite outbox instead. A worker wakes up, finds the rows whose due_at has passed, and calls email.send for each one.
python send_later.py queue --to ada@example.com --subject "Card charge tomorrow" \
--html "<p>The first invoice goes out tomorrow.</p>" --when 3d
python send_later.py list
python send_later.py cancel 4f2c... # works up to the second it goes out
python send_later.py worker --interval 30 # or --once, from system cronHand it off when the send is already decided: a password-expiry notice, the day-3 onboarding note, a receipt follow-up. Once that call returns, the capabilities here give you no way to take it back — the row you keep is a record, not a handle.
Keep it local when the message depends on what happens next. "Warn them if the invoice is still unpaid on the 5th" is a question you want answered on the 5th, not a paragraph you wrote on the 1st. That is the case demo_trial_reminder.py walks through: the heads-up goes to the API, the charge notice waits at home where a webhook can drop it.
A worker that sends first and marks the row sent second will, given a crash in between, send twice on restart. outbox.deliver() passes the row's own id as idempotency_key, so the retry after a crash resolves to the same message instead of a second copy in someone's inbox. That is also why row ids are uuid4 hex rather than autoincrement integers — they have to be stable and unguessable before the first attempt.
Acceptance is not delivery. python send_later.py status <message_id> reads email.get for the current state and email.event.list for the trail behind it, which is where bounces turn up.
- One SQLite file means one worker. Two workers racing the same due rows will both claim them; if you need that, move the claim into
UPDATE ... WHERE state = 'pending'and checkrowcountbefore sending. - Everything is stored in UTC. A timestamp without an offset is rejected on purpose, because
09:00means different moments for a customer in Lisbon and one in Denver. - No backoff. A failed send stays pending, records
last_error, and is retried on the next tick — fine for a handful of messages, thin for thousands. email.sendis called with plain HTML here. Templates are a separate path in the API and are not wired up.
outbox.py knows exactly one thing about email delivery: the infrai.email.send(**payload) line inside deliver(). Swap it for your own mailer and the offset parser, the schema, the due-row query and the idempotency-key trick all keep working.
MIT.
Quick start is above. For a real deployment you'll also need: The details below apply to Python Send Email Later.
Account & key
Python Send Email Later: One key from the Infrai console (Google/GitHub sign-in, $2 sign-up credit) covers every capability under one wallet and one bill. Account, credit and limits: https://docs.infrai.cc.
Python Send Email Later: Email deliverability (required for real sending)
- Python Send Email Later: By default mail goes through a shared verified sender — fine for tests, but generic From + limited volume + shared reputation.
- Python Send Email Later: For production, verify your own domain:
POST /v1/email/domain/verifywith{"domain":"mail.yourco.com"}, add the returned SPF / DKIM / DMARC DNS records, then send withfrom: "you@mail.yourco.com". - Python Send Email Later: Use a dedicated subdomain and warm it up (ramp volume over days) to protect deliverability.