Product
Sendy
Your app needs to send an email. There is a bit more to it than the API call.
Transactional email platform with HTTP and SMTP sending, a Rust delivery worker, and a Vue console for managing domains, API keys, and email status.
- The work
- Product
- The toolkit
- Rust / Axum / Tokio / Toasty ORM / Vue.js / Vite SSG / TypeScript / Amazon SES / MySQL / Redis
- HTTP / SMTPAn application submits a message
- QueueWork waits independently of the request
- Rust workerScheduling, delivery, and eligible retries
- Amazon SESThe message enters the sending infrastructure
A queued message still has to be processed and sent. That does not guarantee inbox delivery.
01 / Sendy
Sending the email
Imagine you are adding password resets to an application. You need to send an email from your domain, then get back to building the actual product. Sendy gives you an HTTP API and SMTP for that. Underneath, it sends transactional email through Amazon SES.
02 / Sendy
What happens after the request
Now, accepting a request and sending the email are two different jobs. The API is built with Axum, MySQL stores the data, and Redis holds queued work. A separate Rust worker processes the messages, gives active accounts turns, and retries eligible failures. If your application retries an HTTP request, an idempotency key lets Sendy return the original jobs instead of queuing the same email again.
03 / Sendy
Why Toasty?
Before Toasty, I was not sure Rust ORMs were ready for the kind of application I wanted to build. Diesel did not give me what I wanted, and bare SQLx would leave more of the SQL and data access code in my hands. Toasty ORM was a better fit. I define models with its procedural macros, and Toasty uses that schema information to generate migration SQL. I still get SQL migrations I can review and keep in the repository.
04 / Sendy
Keeping migrations under my control
The migration tooling is another reason I like it. I use toasty_cli as a library, so the CLI entry point lives in my codebase. I can decide how it loads configuration and which database it uses, without requiring a separately installed Toasty executable. For a small app, I like being able to bundle the migrator into the main binary and run it at startup. That keeps the application contained. Sendy currently has a dedicated migrate binary using the same application configuration. As Sendy grows, migrations will definitely run separately from application startup; I do not want every application instance trying to migrate the database.
05 / Sendy
The rest of the application
There is also the part you need before you can send anything: verifying domains, creating API keys, checking message status, and buying credits. I built a Vue and TypeScript console for that. Credits can be topped up in Kenyan shillings without a monthly subscription. The docs cover both HTTP and SMTP, so you can use the integration your application already understands.
06 / Sendy
One frontend, by choice
Many SaaS products have website.com for the public site, app.website.com for the console, and docs.website.com for documentation. I wanted Sendy to have one frontend application. The public website, docs, and console all live together because that is how I wanted the product to work. That decision informed the backend architecture too: Axum serves the HTML and assets for that one application through tower-http.
07 / Sendy
Deploying it like a monolith
I wanted Sendy to feel like deploying a monolith, even though the Vue frontend, Rust API, and delivery worker are separate parts. The inspiration came from how Mailpit works. I liked that contained deployment experience, but I did not want to embed HTML and the rest of the frontend assets into the executable. Instead, the backend knows where the built files live and serves them from disk through Axum, using tower-http’s static file services. The console and API share an origin, and the console does not need its own frontend server in production. In development, the backend starts Vite if needed and proxies frontend requests to it. I still open the application through the backend. My Axum-template repository shows the same approach, although it is slightly behind what Sendy does now.
08 / Sendy
Static pages and a Vue console
The production application knows nothing about Vite. Vite SSG generates the public pages and documentation at build time, and the Rust server serves the resulting files. That removes my need for Nuxt here; I find it too memory-hungry for this job. The console is a simple Vue SPA. It still loads the data a destination needs before completing navigation, so I get the Nuxt-like behaviour I want when moving between pages. The router fetches the required resources before resolving the route and hands the results to the page.
09 / Sendy
How do you know it is working?
Now, how do you know this is working? A process can be running while the queue goes nowhere. I added Prometheus metrics for the API and worker so you can look at request latency, database operations, send attempts, and the age of work that is actually due. Provider acceptance and recipient delivery are separate measurements. The repository also includes a monitoring setup for local Prometheus storage and Grafana Cloud queries, with alert rules for stalled workers, stale snapshots, and overdue mail. The alert receiver still has to be configured; adding a rule does not automatically notify anyone.
A closer look
Engineering notes
- HTTP and SMTP
- An HTTP API for new integrations and an SMTP interface for existing mail libraries.
- Queued delivery
- A Rust worker handles delivery through Amazon SES, retries, and fair scheduling across accounts.
- Sending console
- Manage verified domains, API keys, prepaid credits, and email status from a Vue dashboard.
- Why Toasty ORM
- Diesel did not fit what I wanted, and I wanted more help with models and migrations than bare SQLx. Toasty’s model derives provide schema metadata; its migration tooling compares that schema with the previous snapshot and generates SQL files I can review.
- A migration CLI I control
- The repository wraps toasty_cli in its own migrate binary, sharing Sendy’s configuration and supporting a test database switch. Using the CLI as a library lets me choose how it works. An embedded startup migrator suits a contained app; as Sendy grows, migrations belong in a separate deployment step.
- One frontend, two rendering paths
- The public site, docs, and console share one Vue application. Vite SSG generates public HTML at build time; console routes use the SPA shell. Production serves those files through Axum without a Vite or Nuxt server. Vue Router waits for required API reads before completing navigation, then hands the results to the page.
- Deploying it like a monolith
- Inspired by Mailpit, the API serves the built console from a configured directory using tower-http’s ServeDir and ServeFile services on Axum. The assets stay on disk rather than inside the executable. Development uses the same backend entry point, with frontend requests proxied to Vite. The delivery worker still runs separately.
- Measure the work, not just the process
- HTTP counts and latency, representative database operations and pool state, SES timings and failures, Redis boundaries, and SMTP rejection categories give different views of the same sending path. A successful worker loop advances a progress timestamp even when the queue is idle.
- Queue age needs some context
- Queue aggregates are refreshed on a timer, independently of Prometheus scrapes. Future-scheduled work is excluded from overdue age. A failed refresh preserves the previous values and timestamp, so freshness matters. Workers report the same shared database totals; the dashboard uses max across instances instead of adding those totals together.
- Keep the labels bounded
- HTTP metrics use route templates, and SMTP rejections use fixed reason categories. Arbitrary SQL, customer addresses, tenant IDs, job IDs, and raw error messages stay out of metric labels. That limits the number of time series and keeps customer details out of the metrics.
- A separate monitoring path
- API and worker metrics use separate loopback listeners. The deployment templates configure Prometheus with 60 days of local retention, MySQL collection through mysqld_exporter, and Grafana Cloud querying through an authenticated Nginx endpoint. Alertmanager needs a receiver before operator notifications work, and those alerts should not depend on Sendy’s customer email queue. Trace export is deferred.
Keep exploring
More projects
If this one interests you, you might want to look at these too.
Farm Direct Marketplace
A marketplace has to work beyond the browser.
Connecting smallholder farmers with buyers through a web platform, USSD, and WhatsApp, with tools for the youth agents coordinating sales and delivery.
Laravel / PHP / Node.js / TypeScript
Read the storyLnky
Remove the tracking parameters before you share that link.
Privacy-focused Progressive Web App that expands shortened URLs and removes tracking parameters.
Vue.js / TypeScript / Rust / PWA
Read the storyGet in touch
Working on something similar?
If you’d like help with a project or want to discuss something here, hit me up.