Transactional Email logo

Transactional Email

Community
phuryn
transactional-email

Adding transactional or alert email, choosing a provider, 'sent twice', 'never arrived': app email sent once, never lost, with one-click unsubscribe.

Overview

Publisherphuryn
Repositoryproduct-engineering
Skill nametransactional-email
Stars
189
Forks
27
Bundled files
1
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by phuryn on GitHub. Read the source before you install it.

Installation

Install the Transactional Email AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/phuryn/product-engineering/tree/main/app-stack/skills/transactional-email
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/phuryn/product-engineering.git /tmp/product-engineering
mkdir -p .claude/skills
cp -r /tmp/product-engineering/app-stack/skills/transactional-email .claude/skills/transactional-email
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Transactional Email in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Transactional Email on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Transactional Email is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Transactional email: durable receipts, honest about duplicates

An app that sends email has two ways to fail its users: a message that should have gone and didn't, and the same message twice. A plain "insert, then call the provider" does both, depending on where it crashes. This skill is the shape that loses nothing and sends a duplicate only in a narrow, named case, plus the unsubscribe, environment and domain rules that go with it.

It was learned on a monitoring product that sends alert email (an incident opened, an incident resolved, a test) through Postmark, over its HTTP API with plain fetch, from a scheduled sender, named here only by kind. The mechanics carry to Resend, SES or any provider with an HTTP send API; where providers differ, both are said, with the date they were read. The identity provider's own email (sign-in codes, invitations) is not covered here: it sends that itself.

Terms used here. An event is the thing that happened (an incident opened, an invoice paid). A message is one email about it to one recipient. A receipt is the durable row that says what became of that message. The sender is the code that turns pending receipts into provider calls, wherever it runs.

What binds

  1. The event and its pending message commit together. An outbox row is written by a trigger on the event's table, in the transaction that writes the event: an event never exists without its pending message, and an event that rolls back never sends. Code that inserts the event and "then queues the email" has a crash window between the two. (2026-10-22, a monitoring product: its incidents table's trigger adds the alert to an outbox. Its writers were the owner of its definer functions and the server's secret key, which could already insert the outbox, so they needed no change and gained no privilege. A restricted writer needs a definer trigger: references/delivery.md § The outbox.) The outbox row is unique per event and kind, so a trigger that fires twice still queues one.
  2. One receipt per message, unique, written before anything is sent. The unit is event × channel × recipient, with a unique constraint, so expanding an event into its receipts twice adds nothing. Recipients are resolved at send time, from the identity provider for members, and a receipt holds the member's id, never their address. If the identity provider can't be read, the event waits; it is never expanded into an empty list. → references/delivery.md § The receipt
  3. Claim under a lease, send only while the claim is fresh, mark only while it is still yours. The sender claims due receipts with for update skip locked, so two senders never claim one, and a pass may run any number of times, overlap another, or be missed and caught up later. It sends only well inside the lease, so a sender that stalled cannot send a receipt another has since taken over, and it marks a receipt only if the claim is still its own. → references/delivery.md § The claim
  4. Classify the provider's answer, because each answer means something different:
    • accepted (a message id): sent, with the id;
    • refused this message (an invalid address, an inactive recipient): failed, in your own words, never retried, because sending it again can't help;
    • didn't take it (a 429, a 5xx, a refused token, or the request never left): back to pending, with backoff, and failed after a fixed number of tries;
    • no answer (a timeout, a dropped connection, a crash between the provider accepting and your mark): it stays sending, and nobody knows whether it went. That case is item 5.
  5. An unanswered send is settled, never resent blind.
    • Where the provider takes an idempotency key, send one, the receipt's id. Resend accepts an Idempotency-Key header and keeps it for 24 hours: a retry with the same key and body returns the first answer without sending again (Resend's docs, read 2026-10-25).
    • Where it has none, put the receipt's id in the message's metadata and ask the provider's message search before sending again. Postmark has no idempotency key (its support article, read 2026-10-25), so a retried POST /email is a second email; GET /messages/outbound?metadata_<key>=<receipt id> answers whether it was accepted.
    • Ask only after the lease has run out, never straight after the send: the search lags acceptance and flickers before it settles. (2026-10-22, a monitoring product's drill against the real API: a message first appeared 4 to 10 seconds after acceptance, then answered found and not found by turns for a few seconds more. An earlier version of the drill asked as soon as the message first appeared, got "not found" on the next call, and sent a second copy.)
    • Found: mark it sent with that id. Not found: send it. The search itself failing: wait.
  6. There is no exactly-once. Name what remains. A duplicate can still go out if the provider accepts a request long after the sender gave up on it (beyond the lease, or beyond an idempotency key's lifetime), or if the search has not settled by the time it is asked. Neither was seen in the product this came from, but its own notes say both can happen. So the design says at least once, with duplicates made rare, and nothing in the product's copy, docs or terms promises more. A channel that cannot be asked at all (a chat API with no search) goes the other way: an unanswered send is marked failed with words saying it may have arrived, and is never sent again, so it is at most once. Choose per channel, and write down which. → references/delivery.md § What remains
  7. Unsubscribe is one-click by POST, and a GET never acts. Every message that can be unsubscribed carries List-Unsubscribe: <https://…/unsubscribe/<token>> and List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). → references/delivery.md § Unsubscribe
    • Mail scanners open links. So the one-click address acts only on a POST whose body is List-Unsubscribe=One-Click, answers it directly (RFC 8058 forbids a redirect), and needs no cookie or sign-in, because the token is the authority. A GET to it goes to a page that checks the token, shows what will stop, and asks. (2026-10-22, a monitoring product: a review found the one-click route acting on a POST without RFC 8058's body; it now refuses one.)
    • The token is an HMAC of the receipt's id, deterministic, so a retried send carries the same link. It stops only what that receipt reached. Rotating its secret breaks every link already sent, so that secret is rotated only after a leak.
    • The headers must be covered by the message's DKIM signature, or receivers may not offer one-click.
  8. An address someone typed gets one confirmation email, and nothing else until it is confirmed. The confirmation link carries a random token stored only as a hash, expires (days, not weeks), and is confirmed by a button press, not by opening the link. Confirmation emails have their own sending budget, per member and per workspace, because they are the one email the app sends to an address it does not know.
  9. One provider server (or account, or project) and token per environment. Development's token never sends to customers, and production's is never in a development file. Rotate a token on the same server: reconciliation searches one server's history, and a new server cannot find what the old one accepted. (2026-10-24, a monitoring product gave production its own Postmark server and token at go-live.)
  10. Development sends every email to the provider's discard address, and production refuses that setting. A development-only redirect (Postmark: test@blackhole.postmarkapp.com, which accepts and never delivers; Resend: delivered@resend.dev, which simulates a delivery) sends alerts, confirmations and tests alike there instead of to their recipient. Each receipt records that it was redirected, and the screen says so. The production build, and every production deploy of the sender, refuse a non-empty redirect: a convention alone lets a copied .env send every customer's email to one address. (2026-10-23, a monitoring product's backlog: nothing refused its redirect in a production build or the production sender's deploy, and its pre-go-live box for it was still unchecked a day later.) The general rule for development-only switches is environments.
  11. Tracking off on every message, not only on the server: no open pixel, no rewritten links, set in each send call (Postmark: TrackOpens: false, TrackLinks: "None"), so a server setting changed later cannot add them. Plain text unless the product needs more. The footer says, in words true for that recipient, why they get the message.
  12. Logs hold ids and counts, never bodies, recipients or the provider's response body. A receipt holds the provider's error number and your own words for it.

Setting it up

  • The sender domain: SPF, DKIM and the provider's return-path (bounce) record, all DNS-only, verified in the provider before the first send. DMARC at p=none with no reporting is where most setups stop; it lets anyone spoof the domain, so rank it as a risk and tighten it once mail flows. (2026-10-24, a monitoring product's infrastructure pass listed its monitoring-only DMARC among the defaults to tighten.)
  • A new provider account may be restricted. Postmark refused mail to other domains with error 412 until the account was approved (observed 2026-10-22); the code reports that in plain words rather than failing silently.
  • The variables: the provider token and the sender address are a pair, both set or both empty; the unsubscribe secret is generated, and is one of the keys that cannot be replaced without breaking what was already sent (environments). The redirect is development-only (item 10).
  • Where the sender runs: anywhere a schedule can call it (a cron on the app's host, a Worker, a worker). A runtime other than the app gets its own database login with only the sender's functions (supabase-production § A second runtime), and is deployed separately, because a push redeploys only the app (release-gate). Overlapping senders are safe by item 3, which is what makes a cut-over from one runtime to another safe.

Proving it

  • Integration tests against a real Postgres with every migration, through the sender's real functions: a retried send; a dropped answer settled by the search with no second send; a crash before the provider; several senders racing over many receipts, each sent once; a refusal not retried; a stale claim that never sends; a follow-up never sent after its first failed. → references/delivery.md § Tests
  • A drill against the provider's real API, to the discard address only, before production: one event, one message, a second pass that sends nothing, and a simulated crash after the send that must be settled from the search without a second copy. The drill waits until the search has answered found several times in a row before the next pass, which is what the lease buys a real sender. (2026-10-22, a monitoring product's drill found the search's flicker this way.)
  • Production's own drill before customers rely on it. (2026-10-24, the same product listed "production's alert path never drilled" as a top risk: the first real incident would otherwise be the first test of delivery.)

How you can tell it worked

A real message reached a real inbox once, a retry did not send a second, and one-click unsubscribe worked from the message as received.

Files

FileHoldsRead when
references/delivery.mdthe outbox and receipt schema, the claim and its timings, the answer table, reconciliation, what remains, ordering between related messages, unsubscribe in detail, the testsbuilding or changing the sender; a duplicate or a missing email; choosing lease and timeout values

Related skills: environments (one value per environment, development-only switches, rotating a secret across every holder) · supabase-production (a second runtime's database login) · release-gate (deploying the runtimes a push does not) · saas-authorization (what may be stored about people; reading names and addresses at send time).

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Transactional Email AI skill do?

Adding transactional or alert email, choosing a provider, 'sent twice', 'never arrived': app email sent once, never lost, with one-click unsubscribe.

Why use Transactional Email on TypingMind?

Because you install it once and use it with any model. Transactional Email is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Transactional Email in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/phuryn/product-engineering/tree/main/app-stack/skills/transactional-email. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Transactional Email?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Transactional Email?

As many as you like. As long as a model supports skills, you can use Transactional Email with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Transactional Email AI skill free?

Yes. It is published on GitHub by phuryn under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇