Sending transactional email from Deno
Deno ships fetch globally, so sending a transactional email is a plain HTTP call with zero dependencies. The one Deno-specific thing worth knowing up front: network access is permission-gated, so a script that wasn't written with this in mind will fail with a permission error the first time it tries to reach Envello's API.
The call
The complete send function:
- const apiKey = Deno.env.get('ENVELLO_API_KEY');
- const res = await fetch('https://api.envello.dev/emails', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to, subject, html }) });
- if (!res.ok) throw new Error(`Envello send failed: ${res.status}`);
Permissions
Run the script with --allow-net=api.envello.dev --allow-env=ENVELLO_API_KEY rather than a blanket --allow-all. Scoping network access to the specific host you're calling, rather than granting it broadly, is a small habit that costs nothing and limits what a compromised dependency could reach if one ever tried to make an unexpected outbound call.
If your application also needs to receive webhooks, add the listening permission: --allow-net=api.envello.dev,0.0.0.0:8000 (or whatever port your server listens on).
Creating a reusable email module
Wrap the send logic in a module so every call site gets consistent error handling and logging.
- Create lib/email.ts with an exported sendEmail function
- Accept { to, subject, html, text? } and return { messageId: string }
- Handle response status codes: 400 (validation, don't retry), 429 (rate limit, retry after delay), 5xx (server error, retry once)
- Log the message ID and recipient domain (not full address) on success
- Throw typed errors (EmailValidationError, EmailRateLimitError, EmailServerError) so callers can handle them differently
Error handling in detail
Parse the API error response for actionable information.
- On 400: const error = await res.json(); tells you exactly what failed (invalid recipient, unverified domain, suppressed address). Surface this to the caller.
- On 429: check the Retry-After header for how long to wait. In a server context, queue the retry. In a script, await a setTimeout.
- On 5xx: retry once with a 1-second delay. If still failing, throw so the caller can decide what to do.
- On network error (fetch throws): the request may have succeeded server-side. If you're sending idempotently (with an idempotency key header), retry safely.
Using with Deno.serve
Deno's built-in HTTP server (Deno.serve) handles transactional email sends triggered by incoming requests.
- Deno.serve(async (req) => { if (req.method === 'POST' && new URL(req.url).pathname === '/send-email') { const body = await req.json(); await sendEmail(body); return new Response('sent'); } })
- For fire-and-forget sends where you don't want to block the response, use queueMicrotask or a simple in-memory queue (but be aware that in-memory queues are lost on process restart)
- For Deno Deploy (serverless), the request context may end after the response is sent, so ensure the email send completes before returning the response
Integration with Fresh
If you're using Fresh (Deno's web framework), send transactional email from route handlers or middleware, never from island components (which run in the browser).
- In a route handler (routes/api/send-email.ts): export const handler = { async POST(req) { const body = await req.json(); await sendEmail(body); return new Response('ok'); } }
- Access env vars with Deno.env.get(), which works in Fresh's server context
- For form submissions, handle the send in the route's POST handler and redirect on success
Webhook handling
Receive Envello delivery events in a Deno server endpoint.
- Parse the raw body: const rawBody = await req.text()
- Get the signature: req.headers.get('x-envello-signature')
- Verify HMAC-SHA256 using Deno's Web Crypto API: const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
- Compute the signature: const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(rawBody))
- Compare the hex-encoded result with the header value
- Handle events: delivered (log), bounced (suppress in your data store), complained (suppress)
Testing
Deno's built-in test runner (deno test) works well for testing the email module. Stub the global fetch function in tests to verify request payloads without making real API calls.
Use Deno's testing utilities: const fetchStub = stub(globalThis, 'fetch', returnsNext([new Response(JSON.stringify({ id: 'msg_123' }), { status: 202 })])); then verify the stub was called with the correct URL, headers, and body.
For integration tests, use Envello's test mode with a real API call that validates the request but doesn't deliver.
Where this fits in a Deno project
Whether you're running a Deno server (Oak, Fresh, or the built-in Deno.serve), a scheduled Deno Deploy cron job, or a standalone script, the fetch call above is identical. Deno doesn't need a build step or transpilation for this, TypeScript works directly, which keeps the integration to the handful of lines above with nothing else to configure.
Check your domain's SPF, DKIM, and DMARC records
Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.
Check your domain →