tommy stands in for the services an application talks to but which are awkward to run locally — mail providers, SMS gateways, file transfer, chat webhooks, and more to come — and shows you exactly what your code sent. It answers the fake vendor APIs the way the real ones do, so official SDKs work against it unmodified, and it gives you a UI, a JSON API and an SSE stream to inspect every message as it arrives.
Point your application's mail, SMS, file-transfer or chat client at tommy instead of Mailjet, SendGrid, Twilio, a real SMTP/FTP/SFTP server or a Slack/ Teams webhook; nothing is ever delivered anywhere, and every message it captured is one API call or one browser tab away.
Nothing to build and no Go toolchain — one line is the whole of the setup:
docker run --rm -p 8811:8811 -p 8822:8822 can3p/tommyThat publishes the two ports everything HTTP needs: the UI and the API on
8811, the fake vendor ingress on 8822. Anything that speaks a protocol of its
own — SMTP, FTP, SFTP, TFTP, NFS, MLLP, SNMP traps — has a listener to publish
as well, and FTP additionally needs to be told an address it can hand back to
a client. docker-compose.yml is that stack already
wired up, and docs/docker.md is the reference for both:
every port, the /data volume, and how to narrow the image down to one
plugin with a mounted config.
A binary you installed or built yourself is the same server:
tommy servetommy is running
ui http://127.0.0.1:8811/ui/
api http://127.0.0.1:8811/api/v1
ingress http://127.0.0.1:8822
plugin mail ([mailjet resend sendgrid smtp])
plugin sms ([twilio])
plugin files ([ftp sftp tftp nfs])
plugin chat ([slack msteams])
plugin hl7 ([mllp])
plugin snmp ([trap])
plugin push ([fcm apns])
plugin s3 ([http])
plugin as2 ([http])
run `tommy providers` for copy-paste examples
In another terminal, send it something:
curl -s http://127.0.0.1:8822/v3.1/send \
-u "any-key:any-secret" -H 'Content-Type: application/json' -d '{
"Messages":[{"From":{"Email":"a@example.com","Name":"Alice"},"To":[{"Email":"b@example.com"}],
"Subject":"Hello from tommy","TextPart":"It works."}]}'Then open http://127.0.0.1:8811/ui/ to see it in the inbox, or:
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=mail"You do not have to go looking for it, though. Every response tommy sends names what it captured, so the link is already in your application's log:
curl -si http://127.0.0.1:8822/v3.1/send -u any:any \
-H 'Content-Type: application/json' -d '{"Messages":[{"From":{"Email":"a@example.com"},
"To":[{"Email":"b@example.com"}],"Subject":"Open me","TextPart":"It works."}]}' | grep -i x-tommy
# X-Tommy-Event-Url: http://127.0.0.1:8811/ui/events/01a07138320d00019f31cb1bThat URL is the mail on a page of its own — the rendered body, the headers, the
raw request. Every event the API returns carries the same link in its url
field, on /api/v1/events, on the SSE stream and on each plugin's own
read-back API, so a test that just sent something can print where to look at it.
tommy providers prints every enabled plugin and provider, its endpoints and
a ready-to-run snippet for each one, rendered against whatever ports your
configuration actually bound — useful before you've sent anything at all.
| Plugin | Providers | What it fakes |
|---|---|---|
mail |
mailjet, sendgrid, resend, smtp |
The vendor HTTP send APIs, plus a real SMTP listener |
sms |
twilio |
The Programmable Messaging REST API (create, list, fetch) |
files |
ftp, sftp, tftp, nfs |
Real FTP, SFTP, TFTP and NFSv3 servers, backed by one shared virtual filesystem |
push |
fcm, apns |
Firebase Cloud Messaging's HTTP v1 send API and Apple's HTTP/2 provider API, shown as lock-screen cards |
hl7 |
mllp |
A real MLLP listener that parses HL7 v2 and answers with a mechanical ACK |
chat |
slack, msteams |
Slack incoming webhooks + chat.postMessage, and both generations of Teams incoming webhook |
snmp |
trap |
A real UDP trap receiver: v1/v2c traps and informs, every varbind decoded by its wire type |
s3 |
http |
A path-style S3-compatible object store on its own listener: buckets, objects, multipart uploads |
as2 |
http |
RFC 4130 EDIINT over HTTP: unwraps signed/encrypted/compressed messages and answers with a real MDN receipt |
Every plugin and provider describes itself: Description(), the endpoints it
mounts, and at least one runnable snippet, surfaced identically in the UI's
"How to test" panel, GET /api/v1/plugins and tommy providers. The
snippets below are exactly those, and every one has been run against a live
tommy serve while writing this document.
curl -s http://127.0.0.1:8822/v3.1/send \
-u "any-key:any-secret" -H 'Content-Type: application/json' -d '{
"Messages":[{"From":{"Email":"a@example.com","Name":"Alice"},"To":[{"Email":"b@example.com"}],
"Subject":"Hello from tommy","TextPart":"It works."}]}'Any Basic-auth credentials are accepted and recorded by default; pin
api_key/secret_key in [plugins.mail.providers.mailjet] to make a
mismatch return Mailjet's real 401.
curl -si http://127.0.0.1:8822/v3/mail/send \
-H 'Authorization: Bearer SG.fake-key' \
-H 'Content-Type: application/json' -d '{
"personalizations": [{"to": [{"email": "bob@example.com", "name": "Bob"}], "subject": "Hello from tommy"}],
"from": {"email": "alice@example.com", "name": "Alice"},
"content": [
{"type": "text/plain", "value": "It works."},
{"type": "text/html", "value": "<p>It <b>works</b>.</p>"}
]
}'Answers 202 with an empty body and an X-Message-Id header, exactly like
the real API.
curl -s smtp://127.0.0.1:1025 \
--mail-from alice@example.com --mail-rcpt bob@example.com -T - <<'MSGEOF'
From: Alice <alice@example.com>
To: Bob <bob@example.com>
Subject: Hello from tommy
It works.
MSGEOFNo AUTH is required; if a client offers one it is recorded, never checked,
unless username/password are pinned in config.
curl -s -u ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:authtokenxxxxxxxxxxxxxxxxxxxxxxxx \
http://127.0.0.1:8822/2010-04-01/Accounts/ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/Messages.json \
--data-urlencode 'To=+15558675310' \
--data-urlencode 'From=+15557122661' \
--data-urlencode 'Body=It works.'Returns the full Twilio message resource (sid, status: "queued",
num_segments, ...), with matching GET list and fetch endpoints served
from the same store — an SDK that creates and then lists a message sees its
own write.
Pointing an official SDK at tommy is its own topic: see clienthelp/ and
docs/clients.md for the per-SDK story, including Twilio's, whose client
libraries need a custom *http.Client rather than a base-URL override.
snmptrap -v 2c -c public 127.0.0.1:1162 '' 1.3.6.1.6.3.1.1.5.3 \
1.3.6.1.2.1.1.5.0 s "host01"Accepts v1 traps, v2c traps and v2c informs, decoding every varbind by its
real wire type (integers, OIDs, counters, gauges, timeticks, IP addresses,
octet strings — hex-dumped when not printable text) rather than flattening
everything to one string. An inform gets a GetResponse back, echoing its
request id and varbinds; a trap, v1 or v2c, gets none — SNMP defines it as
unconfirmed. Any community string is accepted and recorded, never checked.
There is no bespoke tab: the generic event view's JSON payload panel already
shows every varbind, deliberately — see plugins/snmp/README.md.
curl -s -o tommy.pem http://127.0.0.1:8822/as2/certificate
printf 'ISA*00* *00* *ZZ*PARTNER *ZZ*TOMMY *260903*1200*U*00401*000000001*0*P*>~SE*1*0001~IEA*1*000000001~' |
curl -s -D - --data-binary @- \
-H 'AS2-From: PARTNER' -H 'AS2-To: TOMMY' -H 'AS2-Version: 1.1' \
-H 'Message-ID: <1@partner.example>' \
-H 'Content-Type: application/edi-x12' \
-H 'Disposition-Notification-To: as2@partner.example' \
http://127.0.0.1:8822/as2Signed, encrypted and compressed messages are unwrapped layer by layer and
answered synchronously with a real MDN; anything that cannot be opened is
still captured and reported honestly in the MDN's disposition rather than
refused. See plugins/as2/README.md for a full sign-and-encrypt walkthrough
with OpenSSL.
tommy serve runs every plugin and provider compiled into the binary,
filtered by config:
tommy serve --config tommy.toml
tommy serve --ui-port 8811 --api-port 8811 --ingress-port 8822 --bind 127.0.0.1 --host localhost
tommy serve --h2c=false # the ingress serves cleartext HTTP/2 alongside HTTP/1.1 by defaultThe repo root ships tommy.toml, a complete example with
every section commented and every value equal to the built-in default —
copy it and change the two lines you care about. TOML and CLI flags build the
exact same config.Config struct and hand it to the exact same bootstrap
(core/server); nothing about the runtime behaves differently depending on
which one you used.
When you only care about one content type, skip the config file entirely:
tommy mail --ui-port 8811 --in-port 8822 --enabled-providers mailjet,sendgrid
tommy sms --ui-port 8811 --in-port 8822 --enabled-providers twilio
tommy files --ui-port 8811 --in-port 8822 --ftp-port 2121 --sftp-port 2222
tommy chat --ui-port 8811 --in-port 8822 --enabled-providers slack
tommy hl7 --ui-port 8811 --in-port 8822 --mllp-port 2575
tommy push --ui-port 8811 --in-port 8822 --enabled-providers fcm
tommy snmp --ui-port 8811 --in-port 8822 --trap-port 1162
tommy s3 --ui-port 8811 --in-port 8822 --s3-port 9000
tommy as2 --ui-port 8811 --in-port 8822tommy mail, tommy sms, tommy files, tommy chat, tommy hl7,
tommy snmp, tommy s3 and tommy as2 are shortcuts that build a Config
with every other plugin switched off in memory, then run through that
identical bootstrap — there is no second, lighter-weight server.
--enabled-providers narrows which of that plugin's providers run — for s3
that means its sole provider, http; leave it off and every provider the
plugin ships is enabled. An unknown provider name is rejected up front, naming
the valid ones:
$ tommy mail --enabled-providers bogus
Error: unknown mail provider "bogus": valid providers are mailjet, resend, sendgrid, smtp
Every subcommand mirrors serve's flags where they apply: --ui-port,
--api-port, --in-port (the shared ingress port), --bind, --host and
--log-level.
Every provider also gets its own flags for whatever credentials it takes,
named --<provider>-<option> so two providers of one plugin never collide.
Pinning a vendor credential — Mailjet's api_key, Twilio's auth_token,
SMTP's AUTH password — is the same error-path test either way: unset it and
anything is accepted, set it and a mismatch gets that vendor's real error
response:
| Provider | Flags |
|---|---|
mailjet (tommy mail) |
--mailjet-api-key, --mailjet-secret-key |
sendgrid (tommy mail) |
--sendgrid-api-key |
smtp (tommy mail) |
--smtp-port, --smtp-username, --smtp-password |
twilio (tommy sms) |
--twilio-account-sid, --twilio-auth-token |
tftp (tommy files) |
--tftp-port |
nfs (tommy files) |
--nfs-port |
mllp (tommy hl7) |
--mllp-port |
fcm (tommy push) |
--fcm-bearer-token |
apns (tommy push) |
--apns-topic, --apns-key-id |
trap (tommy snmp) |
--trap-port |
ftp (tommy files) |
--ftp-port, --ftp-passive-host, --ftp-passive-ports, --ftp-username, --ftp-password |
sftp (tommy files) |
--sftp-port, --sftp-host-key, --sftp-authorized-keys, --sftp-username, --sftp-password |
http (tommy s3) |
--s3-port |
http (tommy as2) |
--as2-cert-file, --as2-key-file, --as2-partner-cert-file, --as2-cert-dir, --as2-common-name, --as2-in-memory, --as2-to, --as2-max-body |
slack and msteams take no provider-specific flags at all: neither reads
any option beyond enabled.
Only the providers with a listener of their own get a --<provider>-port:
smtp, ftp, sftp, tftp, nfs, mllp, trap, and s3's http. Every other HTTP
provider — mailjet, sendgrid, twilio, slack, msteams, fcm, apns and as2's
http — shares the one --in-port / [ingress] listener and is told apart by
path, so none of them gets a --<provider>-port flag. s3's http provider is
the odd one out: it speaks HTTP but gets a dedicated port like the protocol
servers, because S3's own /{bucket}/{key} paths would collide with the
other fake vendor APIs mounted on the shared ingress.
An unset flag never overrides a provider's own default, and setting one for a
provider --enabled-providers excludes is a clear error rather than a flag
that silently does nothing:
$ tommy files --enabled-providers ftp --sftp-port 2200
Error: files: flags were given for provider "sftp", but --enabled-providers only enables ftp
Everything else settable in a provider's tommy.toml section — smtp's,
ftp's and sftp's own bind, and tuning knobs like timeouts, message/recipient
limits and connection caps — is deliberately config-file-only; see the
comments next to each key in tommy.toml for why.
Every subcommand rejects a stray positional argument rather than silently
starting a server with it ignored — tommy mail serve and tommy files oops
are both errors, not a running server you have to notice and kill by hand.
Everything lives under /api/v1, shared by every plugin:
| Route | Notes |
|---|---|
GET /health |
status, uptime, enabled plugins, event count, version |
GET /plugins |
descriptions, endpoints and snippets, rendered against the live ports |
GET /events |
?plugin=&provider=&type=&search=&since=&limit=&offset= |
GET /events/{id} |
the full event, including the raw request |
GET /events/stream |
Server-Sent Events, same filters — what the UI itself consumes |
DELETE /events |
?plugin= to scope the clear, otherwise clears everything |
DELETE /events/{id} |
remove one event |
GET /blobs/{id} |
stream an attachment or uploaded file, with range support |
GET /openapi.json |
the OpenAPI 3.1 description of the events API, generated from its routes |
GET /<plugin>/openapi.json |
that plugin's own read-back API, described the same way |
GET /mail/messages, /mail/messages/{id}, .../html, .../text, .../raw, .../attachments/{idx} |
mail read-back |
GET /sms/messages, /sms/messages/{id}, .../media/{idx} |
sms read-back |
Every event in those responses carries a url naming its own page,
GET /ui/events/{id} — one event, rendered by the plugin that captured it.
The events API — the five routes above that list, fetch, stream and delete
events, plus the blob download — has an OpenAPI 3.1 description, generated from
the server's own route table rather than written by hand:
docs/openapi.json, or GET /api/v1/openapi.json from a
running tommy. Generate a client from it and a test can assert on what your code
sent without knowing anything about the vendor it thought it was talking to.
tommy openapi prints it, make openapi regenerates the checked-in copy, and a
test fails if the two ever disagree.
Each plugin's read-back API — /api/v1/mail/messages and its kin, which filter
by the things that only make sense for that content type — has a description of
its own: docs/openapi-mail.json, GET /api/v1/mail/openapi.json, or
tommy openapi mail. One document per surface, so the events description stays
the small one everybody reads.
The fake vendor endpoints are deliberately described nowhere — those are the
vendors' own specifications; tommy providers prints what tommy mounts for
them.
tommy providers [plugin[/provider]] [--json] prints the same information
GET /api/v1/plugins returns, for scripting or a quick look before you've
sent anything.
docs/plan.md— the original design brief.docs/implementation-plan.md— what is planned next, wave by wave. Forward-looking only; finished waves move to the history.docs/contracts.md— the contracts as actually implemented, including the small deviations from the plan.- Every plugin and provider directory carries its own
README.mdwith the same description and snippetstommy providersprints.
Three routes, and they all give you the same binary. The Docker image is the only one that needs nothing installed first.
docker run --rm -p 8811:8811 -p 8822:8822 can3p/tommyPublished on Docker Hub as can3p/tommy and on GHCR as
ghcr.io/can3p/tommy, multi-architecture, one static binary on a distroless
base. docs/docker.md covers publishing the protocol
listeners as well, the /data volume, and narrowing tommy to one plugin with a
mounted configuration.
go install github.com/can3p/tommy@latestDownload tommy and install into a local bin directory.
Latest version:
curl -L https://raw.githubusercontent.com/can3p/tommy/main/generated/install.sh | shSpecific version:
curl -L https://raw.githubusercontent.com/can3p/tommy/main/generated/install.sh | sh -s 0.0.4The script will install the binary into $HOME/bin folder by default, you can override this by setting
$CUSTOM_INSTALL environment variable
Get the archive that fits your system from the Releases page and
extract the binary into a folder that is mentioned in your $PATH variable.
The project has been scaffolded with the help of kleiner
tommy is released under the MIT license; see LICENSE for the
full text.