AdonisJS Mailer Setup: Sending Email through JustEmails
AdonisJS mailer meets JustEmails SMTP. TypeScript config that actually works.
AdonisJS mailer meets JustEmails SMTP. TypeScript config that actually works.
I was three hours into migrating an Express app to AdonisJS when I hit the email part. The old setup used nodemailer with raw SMTP strings hardcoded in four different places. Classic. Four different places! I counted.
AdonisJS has a proper mailer package — @adonisjs/mail — with typed config, Edge templates, and multiple transport support. Every tutorial I found assumed Mailgun or SendGrid though. Nothing for simpler SMTP setups, nothing for flat-fee providers where you're not paying per thousand sends.
So I wrote this. (Partly because I was procrastinating on the actual migration.)
We're the JustEmails team — JustEmails is built by Velocity Digital Labs, same folks behind ClickzProtect for ad fraud protection and JustAnalytics for privacy-first analytics. We've done integration guides for Express, Bun/Hono, Cloudflare Workers, and others, but AdonisJS keeps coming up in support threads. Fair enough — the framework has a devoted community, and the "batteries included" philosophy means people expect email to just work.
(Honestly, I underestimated AdonisJS for years. The TypeScript-first approach and convention-over-configuration design actually make sense once you're building something larger than a weekend project.)
A working AdonisJS mail configuration that sends transactional email through JustEmails SMTP. You'll have:
By the end, you can call await mail.send(...) anywhere in your AdonisJS app and emails go out through JustEmails. Your sends authenticate with SPF/DKIM because you're sending from a verified domain — no spam folder guessing games.
Before starting:
node ace --version to confirm you're on v6Quick note on sending limits: the base $49/year plan includes 1,000 transactional API emails per month. SMTP sends count against this same quota. For verification emails, password resets, receipts — that covers most early-stage apps. Need more? Add 10,000 emails/month for $25/year, stackable indefinitely.
If you're deciding between SMTP vs API for transactional email, this tutorial is on the SMTP side. SMTP integrates cleaner with AdonisJS's built-in mailer. The REST API makes sense if you need per-message webhooks or aren't using the mailer abstraction.
AdonisJS doesn't include mail out of the box. Add it:
node ace add @adonisjs/mail
This installs the package and creates config/mail.ts. The CLI also updates your adonisrc.ts to load the mail provider. You'll see output like:
DONE: Installed @adonisjs/mail
CREATE: config/mail.ts
UPDATE: adonisrc.ts
If you're coming from Express, this is one of those "batteries included" moments. No hunting for compatible packages. No version conflicts at 11pm. The ace CLI handles it.
(I still occasionally forget that AdonisJS has its own CLI. Old habits from the npm-everything days. My muscle memory types npm run before my brain catches up.)
Never hardcode SMTP credentials. I've watched too many people commit secrets to public repos. AdonisJS has first-class env validation — use it.
Add these to your .env:
# JustEmails SMTP
SMTP_HOST=smtp.justemails.app
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App Name"
Get your SMTP username and password from the JustEmails dashboard: Dashboard → Domains → Select your domain → SMTP Settings. The username usually looks like your full email address.
Now register these in start/env.ts (AdonisJS validates env vars at boot):
// start/env.ts
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
// ... existing vars ...
SMTP_HOST: Env.schema.string(),
SMTP_PORT: Env.schema.number(),
SMTP_USERNAME: Env.schema.string(),
SMTP_PASSWORD: Env.schema.string(),
MAIL_FROM_ADDRESS: Env.schema.string(),
MAIL_FROM_NAME: Env.schema.string.optional(),
})
If any required var is missing, AdonisJS fails at startup with a clear error. Much better than getting a cryptic SMTP auth failure at runtime.
Open config/mail.ts and set up JustEmails as your SMTP transport:
// config/mail.ts
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'
const mailConfig = defineConfig({
default: 'justemails',
mailers: {
justemails: transports.smtp({
host: env.get('SMTP_HOST'),
port: env.get('SMTP_PORT'),
secure: false, // STARTTLS on port 587
auth: {
type: 'login',
user: env.get('SMTP_USERNAME'),
pass: env.get('SMTP_PASSWORD'),
},
}),
},
from: {
address: env.get('MAIL_FROM_ADDRESS'),
name: env.get('MAIL_FROM_NAME', 'Your App'),
},
})
export default mailConfig
declare module '@adonisjs/mail/types' {
export interface MailersList extends InferMailers<typeof mailConfig> {}
}
A few things happening here:
secure: false with port 587 means STARTTLS — the connection starts unencrypted then upgrades. Standard for submission ports. Port 465 uses implicit TLS and would need secure: true.auth.type: 'login' is explicit. Some SMTP servers are picky about this. I've wasted hours debugging auth issues that turned out to be the wrong type.from object sets defaults. Override per-email if you want.declare module block at the bottom gives you TypeScript autocompletion for mail.use('justemails'). Worth the boilerplate.Multiple sending domains? Add more transports — one per domain — and switch between them. Not my favorite pattern honestly, but it works.
AdonisJS uses Edge for templating. Create a folder for email templates:
mkdir -p resources/views/emails
Now create a verification email template:
{{-- resources/views/emails/verification.edge --}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #1a1a1a; margin-bottom: 24px;">Verify your email</h2>
<p style="color: #4a4a4a; line-height: 1.6;">
Hi{{ userName ? ` ${userName}` : '' }},
</p>
<p style="color: #4a4a4a; line-height: 1.6;">
Click the button below to verify your email address:
</p>
<a href="{{ verificationUrl }}"
style="display: inline-block; background: #0070f3; color: white;
padding: 12px 24px; text-decoration: none; border-radius: 6px;
margin: 24px 0; font-weight: 500;">
Verify Email
</a>
<p style="color: #888; font-size: 14px; margin-top: 32px;">
If you didn't create an account, you can ignore this email.
</p>
<p style="color: #888; font-size: 14px;">
Or copy this link: {{ verificationUrl }}
</p>
</body>
</html>
Edge templates are basically HTML with {{ variable }} interpolation. The {{ userName ? ... }} syntax handles the optional name — Edge supports JavaScript expressions inside the double braces. Nice.
(I know inline styles are ugly. But email clients still don't handle CSS reliably in 2026 — Gmail strips <style> blocks in many cases. Inline is the least-bad option. I hate it too.)
Create a service or put this in your auth controller. Here's a clean pattern:
// app/services/email_service.ts
import mail from '@adonisjs/mail/services/main'
interface SendVerificationOptions {
to: string
userName?: string
verificationUrl: string
}
export class EmailService {
async sendVerification({ to, userName, verificationUrl }: SendVerificationOptions) {
try {
const response = await mail.send((message) => {
message
.to(to)
.subject('Verify your email address')
.htmlView('emails/verification', {
userName,
verificationUrl,
})
})
return { success: true, messageId: response.messageId }
} catch (error) {
console.error('Email send failed:', error)
// Parse common SMTP errors
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
if (errorMessage.includes('535')) {
return { success: false, error: 'SMTP authentication failed' }
}
if (errorMessage.includes('550')) {
return { success: false, error: 'Recipient rejected' }
}
return { success: false, error: 'Failed to send email' }
}
}
}
Then in your controller:
// app/controllers/auth_controller.ts
import { EmailService } from '#services/email_service'
import type { HttpContext } from '@adonisjs/core/http'
export default class AuthController {
async register({ request, response }: HttpContext) {
// ... user creation logic ...
const emailService = new EmailService()
const token = generateVerificationToken() // your implementation
const verificationUrl = `https://yourapp.com/verify?token=${token}`
const result = await emailService.sendVerification({
to: user.email,
userName: user.name,
verificationUrl,
})
if (!result.success) {
// User created but email failed — decide your policy
// I usually proceed but flag for retry
console.warn(`Verification email failed for ${user.email}: ${result.error}`)
}
return response.created({ user, emailSent: result.success })
}
}
The mail.send() callback gets a message builder. Chain .to(), .subject(), .htmlView(), and optionally .textView() for a plain-text fallback. The builder pattern keeps things readable. I actually like this API — not something I say often about email libraries.
Some corporate email clients prefer plain text. Create a text template:
{{-- resources/views/emails/verification_text.edge --}}
Verify your email
Hi{{ userName ? ` ${userName}` : '' }},
Click the link below to verify your email address:
{{ verificationUrl }}
If you didn't create an account, you can ignore this email.
Update the send call:
await mail.send((message) => {
message
.to(to)
.subject('Verify your email address')
.htmlView('emails/verification', { userName, verificationUrl })
.textView('emails/verification_text', { userName, verificationUrl })
})
Both versions go out in the same email as MIME parts. The recipient's client picks which to display. Most people skip the text version. Don't be most people.
Real talk — you'll probably hit at least one of these.
"535 Authentication failed"
Your SMTP credentials are wrong. Check:
.env?AdonisJS caches env vars at boot. You need to restart. This one gets me every single time.
"550 Sender address rejected"
You're trying to send from a domain that isn't verified in JustEmails. The MAIL_FROM_ADDRESS must match a domain you've added and verified in Dashboard → Domains. Add the DNS records and wait for verification if you haven't.
Emails send but land in spam
Check your email authentication. JustEmails auto-configures SPF/DKIM/DMARC when you verify a domain, but if you added records manually, something might be off. Run a test at mail-tester.com. Our custom domain setup guide covers DNS configuration if things look wrong.
"ECONNREFUSED" or connection timeouts
Either your server can't reach the internet, or there's a firewall blocking outbound SMTP. Port 587 is sometimes blocked on shared hosting. Try:
telnet smtp.justemails.app 587
If that hangs or refuses, it's a network issue on your end.
"Missing environment variable" at startup
You added vars to .env but didn't register them in start/env.ts. AdonisJS validates the schema on boot. Add the missing schema entries.
Use AdonisJS's mail fake for tests:
// tests/functional/auth.spec.ts
import { test } from '@japa/runner'
import mail from '@adonisjs/mail/services/main'
test.group('Auth registration', () => {
test('sends verification email on signup', async ({ client, assert }) => {
const { mails } = mail.fake()
const response = await client.post('/register').json({
email: 'test@example.com',
password: 'securepassword',
})
response.assertStatus(201)
assert.isTrue(
mails.exists((mail) => {
return mail.to?.includes('test@example.com') &&
mail.subject === 'Verify your email address'
})
)
mail.restore()
})
})
The fake traps all sends and lets you assert against them without hitting real SMTP. Essential for CI pipelines. I've seen people skip this and test against real SMTP in CI — don't.
You've got AdonisJS sending email through JustEmails. What now?
Queue your emails. Right now, mail.send() blocks the request while waiting for SMTP. For production, push emails to a queue (AdonisJS has a jobs package) and process them async. Users don't wait, and you can retry failures.
Add more templates. Password reset, welcome email, receipts. Same pattern — Edge template, service method, controller call. Keep templates in resources/views/emails/ and they're easy to find.
Monitor your quota. JustEmails Dashboard → API → Usage shows your monthly sends. The base $49/year plan includes 1,000/month. If you're approaching that, add a 10,000/month tier for $25/year — stackable. Still way cheaper than per-send pricing at Postmark or SendGrid, which adds up fast once you're past the free tier.
If you need analytics alongside your app, JustAnalytics works well with AdonisJS — same privacy-first approach, simple integration. And if you're running paid campaigns to drive signups, ClickzProtect catches invalid clicks before they burn your ad budget.
AdonisJS doesn't get enough credit. The TypeScript-first design, the CLI tooling, the convention-over-configuration approach — less time fighting the framework, more time building features. Email setup is typical: install the package, add config, send. Compare that to wiring up nodemailer manually with Express middleware. Night and day.
Yes. AdonisJS mailer supports custom transports, so you can write a driver that calls the JustEmails HTTP API directly. But for most use cases, SMTP is simpler to configure and works out of the box with AdonisJS's built-in smtp transport. The REST API makes more sense if you need webhook confirmations per-message or you're building something that doesn't fit the mailer abstraction.
The base $49/year plan includes 1,000 transactional API emails per month. SMTP sends count against the same quota. If you need more, add 10,000 emails/month for $25/year — stackable as many times as needed. Most early-stage apps doing verification emails and password resets stay well under 1,000 monthly.
Yes. The @adonisjs/mail package works the same in AdonisJS 6. The config lives in config/mail.ts, you define transports there, and the Mail.send() API is unchanged. The Edge templating also works identically. This tutorial covers AdonisJS 6 syntax specifically.
Usually it's missing email authentication on your domain. JustEmails auto-configures SPF, DKIM, and DMARC when you verify a domain in the dashboard, but if you added records manually, something might be misconfigured. Run your domain through mail-tester.com to see what's failing. Also check that your FROM_EMAIL env var matches a verified domain in your JustEmails account.
Unlimited custom domain email hosting for $49/year flat — unlimited domains, unlimited mailboxes, 10 GB storage, full IMAP/SMTP. Built for agencies, freelancers, and anyone managing email across more than one domain.