// email sandbox
Point your app’s SMTP settings at smtp.mailqa.io with your organization’s credentials. Everything it sends is captured, filed by recipient, rendered and searchable — and none of it is delivered. The same code runs in every environment; only the host name changes.
# the only lines that differ from production
SMTP_HOST=smtp.mailqa.io
SMTP_PORT=587 # or 2525
SMTP_USER=your-org
SMTP_PASS=•••••••••••• # Settings → SMTP// how it works
Your organization has one SMTP username and password — shown in the dashboard, and returned with every inbox the API creates. Put them in the environment your tests or your staging build run with.
Your app sends to the addresses it would send to anyway: your own subdomain, a customer’s Gmail, a whole list. The sandbox accepts all of them and delivers none. Nothing in your fixtures is rewritten.
Each message is parsed the moment it lands and appears in the dashboard without a refresh. In a test, waitForMessage resolves the same moment, with the links and codes already extracted.
// configure your mailer
Every mailer speaks the same protocol, so every snippet says the same four things: the host, port 587 with STARTTLS, and the username and password from your environment. Copy the one for your framework into the configuration your tests or your staging build load.
import nodemailer from 'nodemailer';
// One environment variable separates local dev, CI and staging from
// production: point SMTP_HOST at the sandbox and nothing leaves it.
export const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST, // smtp.mailqa.io
port: Number(process.env.SMTP_PORT ?? 587),
secure: false, // STARTTLS is negotiated on 587
auth: {
user: process.env.SMTP_USER, // your organization's SMTP username
pass: process.env.SMTP_PASS,
},
});
await mailer.sendMail({
from: 'Acme <noreply@acme.dev>',
to: 'signup+run-4187@your-org.mailqa.io',
subject: 'Verify your email address',
html: '<a href="https://acme.dev/verify?t=9f2c">Confirm email</a>',
});Nodemailer is what the mailQA dev tools send with; secure: false on port 587 means STARTTLS, not plaintext.
# The same block works in development.rb and test.rb — only the
# environment the credentials come from changes.
config.action_mailer.delivery_method = :smtp
config.action_mailer.perform_deliveries = true
config.action_mailer.smtp_settings = {
address: "smtp.mailqa.io",
port: 587,
user_name: ENV.fetch("MAILQA_SMTP_USER"),
password: ENV.fetch("MAILQA_SMTP_PASS"),
authentication: :plain,
enable_starttls_auto: true,
}Leave perform_deliveries on: the sandbox is where the delivery goes, so there is nothing to suppress.
import os
# The staging / CI settings module. The console backend shows you the
# mail once; the sandbox keeps it, renders it and lets a test wait on it.
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtp.mailqa.io"
EMAIL_PORT = 587
EMAIL_USE_TLS = True # STARTTLS
EMAIL_HOST_USER = os.environ["MAILQA_SMTP_USER"]
EMAIL_HOST_PASSWORD = os.environ["MAILQA_SMTP_PASS"]
DEFAULT_FROM_EMAIL = "Acme <noreply@acme.dev>"EMAIL_USE_TLS is Django’s name for STARTTLS; EMAIL_USE_SSL is the implicit-TLS variant and is not what port 587 speaks.
# Laravel's mail config reads straight from the environment, so the
# sandbox is a different .env, not different code.
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailqa.io
MAIL_PORT=587
MAIL_USERNAME=your-org
MAIL_PASSWORD=
MAIL_FROM_ADDRESS=noreply@acme.dev
MAIL_FROM_NAME="Acme"Symfony Mailer, which Laravel sends through, negotiates STARTTLS on port 587 by itself — no MAIL_ENCRYPTION line is needed on current releases.
# Activate with SPRING_PROFILES_ACTIVE=staging; JavaMailSender picks
# these up with no code change.
spring:
mail:
host: smtp.mailqa.io
port: 587
username: ${MAILQA_SMTP_USER}
password: ${MAILQA_SMTP_PASS}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: truestarttls.required makes a missing upgrade an error rather than a silent plaintext login, which is the right default for a credential.
using MailKit.Net.Smtp;
using MailKit.Security;
// MailKit is the client Microsoft points to now that
// System.Net.Mail.SmtpClient is obsolete.
using var client = new SmtpClient();
await client.ConnectAsync("smtp.mailqa.io", 587, SecureSocketOptions.StartTls);
await client.AuthenticateAsync(
Environment.GetEnvironmentVariable("MAILQA_SMTP_USER"),
Environment.GetEnvironmentVariable("MAILQA_SMTP_PASS"));
await client.SendAsync(message);
await client.DisconnectAsync(quit: true);SecureSocketOptions.StartTls is the explicit form; Auto would also work on 587, but naming it means a downgrade fails loudly.
// net/smtp upgrades to STARTTLS on its own when the server offers it,
// and PlainAuth refuses to send the password over plaintext otherwise.
auth := smtp.PlainAuth(
"",
os.Getenv("MAILQA_SMTP_USER"),
os.Getenv("MAILQA_SMTP_PASS"),
"smtp.mailqa.io",
)
err := smtp.SendMail(
"smtp.mailqa.io:587",
auth,
"noreply@acme.dev",
[]string{"signup+run-4187@your-org.mailqa.io"},
msg,
)The standard library is enough here. If you are on go-mail or gomail, the same host, port and credentials go in its dialer.
# A one-line smoke test from any machine with swaks on it —
# no application, no framework, just SMTP.
swaks --server smtp.mailqa.io:587 --tls \
--auth-user "$MAILQA_SMTP_USER" --auth-password "$MAILQA_SMTP_PASS" \
--from noreply@acme.dev \
--to signup+run-4187@your-org.mailqa.io \
--header "Subject: Your login code is 481330" \
--body "Your login code is 481330."The message lands in the signup inbox with the tag run-4187, and the dashboard extracts 481330 as a code before you have switched tabs.
// filed by recipient
The sandbox does not care who your app thinks it is writing to. An address on your own subdomain lands in that inbox, tag and all, exactly as it would over public MX. Anything else — a real customer’s address, a colleague’s, a list — gets an inbox named for it, created the first time mail arrives.
That is what makes it safe to point a staging environment full of production-shaped data at it: the addresses stay real, and nobody outside your organization receives anything.
| Your app sends to | It lands in | Because |
|---|---|---|
| qa@your-org.mailqa.io | the qa inbox | An address on your own subdomain is routed exactly as the public MX would route it. |
| qa+reset@your-org.mailqa.io | the qa inbox, tagged reset | Plus-addressing tags the message, so one inbox covers a whole flow and the API filters by tag. |
| customer@gmail.com | an inbox named customer@gmail.com | Any other recipient gets an inbox of its own, created on first use. Nobody at Gmail hears about it. |
| qa@your-org.mailqa.io, cto@acme.dev | both inboxes, one copy each | A message is filed once per recipient inbox, and each copy is stored and retained on its own. |
// what you get
A console mail backend shows you a message once and forgets it. The sandbox keeps every message for your plan’s retention window, parses it the moment it lands, and hands the parts a test reaches for to you as data.
// sandbox or real delivery
The sandbox catches what your app sends. The public MX address proves it can be delivered — over the real internet, with DNS resolved and the signatures checked. Most teams use the sandbox from a laptop and CI, and the MX address from staging. Every plan has both, and the FAQ says when to pick which.
| SMTP sandbox | Real MX address | |
|---|---|---|
| Address | Anything at all | name@your-org.mailqa.io |
| Needs | Your organization’s SMTP credentials | A verified organization owner |
| Catches | Everything the app sends | Mail sent to your subdomain |
| Proves | What your app sends, and to whom | That delivery works end to end — DNS, SPF, DKIM, DMARC |
| Use it for | Local development, CI, staging | Staging that must behave like production |
// then, in the test
Creating an inbox is idempotent, so a test can open the same one on every run. waitForMessage() then resolves as soon as the sandbox has parsed a match, and the code your app put in the subject is already in codes. No JavaScript on your side? The REST API answers the same question with one polling call.
import { MailQA } from '@mailqa/client';
const mailqa = new MailQA({ apiKey: process.env.MAILQA_API_KEY! });
const inbox = await mailqa.inboxes.create({ name: 'signup' });
// Resolves the moment the sandbox has parsed the message —
// no sleep, no polling loop of your own.
const message = await mailqa.waitForMessage({
inbox: inbox.id,
subjectContains: 'login code',
});
expect(message.codes[0]).toBe('481330');Credentials are provisioned with the organization. Paste them into an environment file and send something.