Sending Email with SendGrid — and Doing It from a Background Job
Email delivery is one of those features that quietly turns a synchronous request into a slow one. The user submits a signup form, your controller posts to SendGrid's API, the API takes 300 ms on a good day and several seconds on a bad one — and the user waits. The fix is not "make HTTP faster"; the fix is to take the work off the request path entirely.
This post walks through two things together: building a thin SendGrid wrapper as a Soli library, then handing the delivery off to a SolidB-backed background job so the controller returns immediately. By the end you will have a SendGrid.send_mail(...) you can call from anywhere, an EmailJob you can enqueue with one line, and a clear mental model of why those two pieces belong on opposite sides of the queue.
The Shape of the Solution
Three files and a route do the whole job:
lib/sendgrid.sl # thin wrapper around the v3 Messages API
app/jobs/email_job.sl # static perform — receives args, calls SendGrid
app/controllers/users_controller.sl # enqueues EmailJob.perform_later(...)
The controller never touches SendGrid. It validates input, persists the user, enqueues an email, and returns. The job runs out-of-band on a worker triggered by SolidB and is the only piece that talks to api.sendgrid.com.
Step 1: A Thin SendGrid Wrapper
Wrap the SendGrid v3 Messages API once, in a place you can reuse from any job, controller, or one-off script. Put it under lib/ so it sits next to your application code without polluting app/.
# lib/sendgrid.sl
class SendGrid
SEND_URL = "https://api.sendgrid.com/v3/mail/send"
static def send_mail(to, subject, body, from = nil)
api_key = getenv("SENDGRID_API_KEY")
if api_key == nil or api_key == ""
raise("SENDGRID_API_KEY is not set")
end
sender = from
if sender == nil or sender == ""
sender = getenv("SENDGRID_FROM") || "[email protected]"
end
payload = {
"personalizations": [{"to": [{"email": to}]}],
"from": {"email": sender},
"subject": subject,
"content": [{"type": "text/plain", "value": body}]
}
response = HTTP.post_json(SEND_URL, payload, {
"headers": {
"Authorization": "Bearer #{api_key}",
"Content-Type": "application/json"
}
})
# SendGrid returns 202 Accepted on success, with an empty body.
if response["status"] >= 200 and response["status"] < 300
return {"ok": true, "status": response["status"]}
end
{"ok": false, "status": response["status"], "body": response["body"]}
end
end
A few things to notice:
getenv("SENDGRID_API_KEY")is read insidesend_mail, not at module load. That means changing the key in.envand hot-reloading does the right thing in dev. It also means each call pays for one cheap env lookup — trivial compared to the HTTP round trip.HTTP.post_jsondoes the JSON serialization andContent-Typefor us. We still setAuthorizationexplicitly because that's the API contract.- A 202 with an empty body is the success case. Treating any 2xx as
ok: truekeeps the wrapper boring; the caller decides what to do with non-2xx. - No retry logic in the library. Retries belong to the queue, not to the library. SolidB will re-fire the job if the handler raises or returns a 5xx — letting the wrapper stay thin.
Mention this wrapper from anywhere in the codebase:
import "/lib/sendgrid.sl"
Step 2: The Email Job
Now the asynchronous half. Soli's background-job system loads every class under app/jobs/, registers a static perform(args) entry point, and gives you perform_later, perform_now, perform_in, and perform_at for free. The full mechanics live in /docs/jobs; the short version is:
# app/jobs/email_job.sl
import "/lib/sendgrid.sl"
class EmailJob
static def perform(args)
to = args["to"]
subject = args["subject"]
body = args["body"]
from = args["from"]
result = SendGrid.send_mail(to, subject, body, from)
if !result["ok"]
# Raising re-surfaces a 5xx back to SolidB, which schedules a retry.
raise("SendGrid rejected #{to}: #{result["status"]} #{result["body"]}")
end
result
end
end
Two design choices worth flagging:
argsis a plain hash. Job arguments round-trip through JSON via SolidB's queue, so primitive types only — no model instances, no closures, no dates asTimeobjects. Pass IDs and strings; rehydrate insideperformif you need the record.- Failure is communicated by raising. Soli's
/_jobs/run/:nameroute turns an uncaught exception into a500. SolidB sees the 500, incrementsattempt, and re-enqueues per its retry policy. There's no separate "tell the queue I failed" call — the absence of a normal return is the signal.
Step 3: Enqueue from a Controller
This is the line the user actually waits for:
# app/controllers/users_controller.sl
import "/app/jobs/email_job.sl"
class UsersController
def create
user = User.create(params["user"])
if user._errors
return {"status": 422, "json": {"errors": user._errors}}
end
EmailJob.perform_later({
"to": user.email,
"subject": "Welcome to Acme",
"body": "Hi #{user.name}, your account is ready."
})
redirect("/users/#{user.id}")
end
end
perform_later calls Job.enqueue("EmailJob", args) under the hood, which POSTs the job to SolidB's /_api/database/{db}/queues/{queue}/enqueue endpoint. SolidB acks the enqueue in a few milliseconds and the controller returns. Whatever SendGrid is doing — and however many seconds it spends doing it — happens out of sight on a worker that gets called back by SolidB later.
Want to delay the send instead? Same API, different verb:
EmailJob.perform_in("2 minutes", {"to": user.email, "subject": "...", "body": "..."})
EmailJob.perform_at("2026-06-01T08:00:00Z", {"to": user.email, "subject": "...", "body": "..."})
Want to run it inline for a test? EmailJob.perform_now({...}) skips the queue entirely and calls perform in-process.
Step 4: Why SolidB Owns the Queue
The job system uses SolidB for three things that would otherwise be three different pieces of infrastructure:
- Durable storage of pending work. When SolidB acks the enqueue, the job survives a Soli restart, a crash, and a redeploy. The user does not have to retry signup just because you shipped a hotfix.
- Scheduling.
perform_inandperform_atare queue inserts with anot_beforetimestamp. SolidB releases them when their time arrives — no second Cron daemon, noatjob, nosetTimeout. - Retries. A 5xx from the callback tells SolidB to re-fire after a backoff. You declare the retry policy on the SolidB side; Soli's contract is "return a status code." This is exactly the boundary you want: the language runtime doesn't pretend to know how many times to retry the API.
Configure it with three env vars (SOLI_JOBS_DATABASE, SOLI_JOBS_CALLBACK_URL, SOLI_JOBS_SECRET) and the system bootstraps itself. The full table — including the HMAC-SHA256 signing scheme that protects the /_jobs/run/:name route from being called by anything other than SolidB — is in /docs/jobs#configuration.
Why This Pattern Holds Up
Two boundaries make this clean:
- The library doesn't know about the queue.
SendGrid.send_mailis a synchronous function. It works in a controller, a script, asoli consolesession, or a test. Pulling SendGrid into a separate file like this means a future "send a password reset" feature wires up the same wrapper into aPasswordResetJobwithout copying API code. - The controller doesn't know about SendGrid. It enqueues an email by intent (to, subject, body), not by transport. If you swap SendGrid for SES tomorrow, you touch
lib/sendgrid.slandEmailJob. The controller and the SignupForm don't care.
That separation is what lets the request return in 10 ms while the email still goes out a second later — the user gets their redirect, the email shows up, and you never had to put SendGrid's latency into your SLO.
What's Next
A few directions to extend this:
- Templates. Replace the plain-text body with SendGrid dynamic templates — pass
"template_id"and"dynamic_template_data"instead of"content"in the payload. The wrapper grows by ~10 lines and your controllers stop building HTML strings. - Per-queue routing.
EmailJob.set(queue: "mailers").perform_later(...)puts welcome emails and transactional alerts on different SolidB queues so a backlog in one doesn't starve the other. - Idempotency. SolidB hands you
args["job_id"]andargs["attempt"]on retries. Stamp the user record withwelcome_email_sent_atafter a successful send and short-circuit on attempt > 1 if the field is already set.
The wrapper, the job, and the queue are intentionally small. Adding any of the above is changing one of the three files — not rewiring the architecture.