Sending by SMTP with Duva: settings, code and common errors
Not everything needs a library. A framework, a content management system or an authentication provider already knows how to send by SMTP: all you have to give it is a server address, a user and a password. Duva receives messages by SMTP in addition to the HTTPS API, under the same rules. Here is how to set it up, what the server reads from your message, and how to interpret its replies.
SMTP or the API?
Both paths lead to the same sending queue and the same places to read results: the message is found with GET /messages/{id}, in events, webhooks and statistics. A message received by SMTP follows the same rules as one sent through the API: verified domain, quota, sandbox, suppressed addresses and idempotency.
- Choose SMTP when the software already sends by SMTP and you only want to change server: a WordPress site, Laravel, Django, Rails, an authentication provider.
- Choose the API when you write the sending code yourself. It answers immediately with the identifier and the status, and the official libraries (Node.js, Python, Ruby, Go, PHP) handle retries for you.
Nothing locks you in: you can start with SMTP and move to the API later, on the same domain and with the same key.
The settings
| Setting | Value |
|---|---|
| Server | smtp.duva.ca |
| Port 587 | STARTTLS (recommended) |
| Port 465 | Implicit TLS |
| User | your sending domain, for example example.com |
| Password | that domain's API key (dv_...) |
| Authentication | PLAIN or LOGIN, after encryption |
Three points to remember:
- The user is the domain, not an email address. It is the most frequent mistake:
[email protected]does not work,example.comdoes. - Encryption is mandatory. No authentication and no message goes through in clear text, and port 25 is not offered.
- The key is checked on every message. A revoked or expired key stops working immediately, even on a connection that is already open. Whatever the cause of an authentication refusal, the reply is the same (
535): it does not say which.
The domain must be verified first, as for the API. The DNS steps are described in SPF, DKIM and DMARC: setting up a sending domain.
A first send in Python
The standard library is enough. The API key comes from the environment.
import json
import os
import smtplib
import ssl
from email.message import EmailMessage
msg = EmailMessage()
msg["From"] = "Example <[email protected]>"
msg["To"] = "[email protected]"
msg["Subject"] = "Your order"
msg["X-Duva-Tags"] = "order,confirmation"
msg["X-Duva-Metadata"] = json.dumps({"order_id": "4821"})
msg["X-Idempotency-Key"] = "order-4821"
msg.set_content("Thank you for your order.")
with smtplib.SMTP("smtp.duva.ca", 587, timeout=30) as smtp:
smtp.starttls(context=ssl.create_default_context())
smtp.login("example.com", os.environ["DUVA_API_KEY"])
print(smtp.send_message(msg))
send_message returns an empty dictionary when every recipient is accepted. An exception (SMTPRecipientsRefused, SMTPSenderRefused, SMTPAuthenticationError) signals a refusal: see the table below.
And in Node.js, with Nodemailer
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "smtp.duva.ca",
port: 587,
requireTLS: true,
auth: { user: "example.com", pass: process.env.DUVA_API_KEY },
});
const info = await transport.sendMail({
from: "Example <[email protected]>",
to: "[email protected]",
subject: "Your order",
text: "Thank you for your order.",
headers: {
"X-Duva-Tags": "order,confirmation",
"X-Duva-Metadata": JSON.stringify({ order_id: "4821" }),
"X-Idempotency-Key": "order-4821",
},
});
console.log(info.response);
requireTLS: true refuses to send if the server does not offer encryption, which is the behaviour you want.
Frameworks
For Django, the settings fit in a few lines:
EMAIL_HOST = "smtp.duva.ca"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "example.com"
EMAIL_HOST_PASSWORD = os.environ["DUVA_API_KEY"]
DEFAULT_FROM_EMAIL = "[email protected]"
For Laravel, in .env:
MAIL_MAILER=smtp
MAIL_HOST=smtp.duva.ca
MAIL_PORT=587
MAIL_USERNAME=example.com
MAIL_PASSWORD=dv_...
[email protected]
Rails, WordPress and authentication providers follow the same pattern: server, port 587, user = domain, password = key. The reference also gives the Rails example. For frameworks with an official integration, it gives access to tags, metadata and tracking without writing headers.
What the server reads from your message
- Recipients are those of the SMTP envelope (
RCPT TO). TheToandCcheaders only serve to classify these addresses; any other envelope address receives a blind copy. ABccheader is never read. You can have at most 100 recipients per transaction, and your account plan's ceiling applies. - The sender is the
Fromheader, which must belong to the key's domain (otherwise553). The envelope address (MAIL FROM) is ignored: Duva uses its own return address. - Content: subject, text, HTML,
Reply-Toand attachments, with the same limits as the API. A sandbox account cannot send attachments. - Size: 10 MB at most per message (
552). - Signed or encrypted messages (S/MIME, PGP/MIME) are refused. Duva rebuilds each message, which would break your signature; the DKIM signature that counts is Duva's.
Four Duva-specific headers
They are read, then removed: the recipient never sees them.
| Header | Role |
|---|---|
X-Duva-Tags |
Tags, separated by commas. |
X-Duva-Metadata |
Metadata as a JSON object (string values). |
X-Duva-Track |
opens, clicks or both, separated by a comma (tracking must be enabled for the domain). |
X-Idempotency-Key |
Idempotency key. |
The server's replies
250 means the message is accepted for delivery: it is queued, not delivered. The msg_... identifier is in the reply; keep it to follow the message through the API, events or webhooks (see An email did not arrive). Duva sends no non-delivery email to the sender: a bounce appears only in events.
| Reply | Meaning |
|---|---|
250 |
Accepted for delivery. |
451 |
Try again later (rate limit or temporary failure). |
452 |
Too many recipients in this transaction. |
454 |
Too many authentication attempts: wait. |
530 |
Authentication or encryption required. |
535 |
Invalid credentials (any cause). |
550 |
Refused: quota reached, sending impossible for this account or domain, or idempotency conflict. |
552 |
Message too large. |
553 |
Sender outside the key's domain, or invalid address. |
554 |
Invalid content (for example, no body) or invalid recipient. |
A 4xx reply invites a retry; a 5xx reply is final for that message. Sending software already follows this convention.
Retrying without duplicates
Without X-Idempotency-Key, Duva computes one from the Message-ID and the recipients. Software that resends the same message after a network break therefore creates no duplicate, and gets the same reply. The same Message-ID for other recipients is a different message. If your software generates a new Message-ID on every attempt, set your own key with X-Idempotency-Key.
The errors that come back most
535although the key is right: the user is an email address instead of the domain, or the key belongs to another domain.553: theFromaddress is in a different domain than the key's. Software that replaces the sender (a form plugin, for example) can be the cause.530: encryption is not enabled in the software. On port 587 it takes STARTTLS; on port 465, implicit TLS.- A message accepted but never received:
250says nothing about what follows. Read the recipient's status and its events. - A message sent successfully, but without tags or metadata: the header name is misspelled, or the metadata is not a JSON object.
A checklist
- The sending domain is verified.
- The user is the domain, the password is that same domain's key.
- The port is 587 with STARTTLS, or 465 with TLS.
- The
Fromaddress belongs to the domain. - The software keeps the
msg_...identifier from the reply. - The key is in an environment variable, not in the code.
- You read bounces through the API or a webhook whose signature you verify.