Skip to content

← All articles

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.com does.
  • 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). The To and Cc headers only serve to classify these addresses; any other envelope address receives a blind copy. A Bcc header is never read. You can have at most 100 recipients per transaction, and your account plan's ceiling applies.
  • The sender is the From header, which must belong to the key's domain (otherwise 553). The envelope address (MAIL FROM) is ignored: Duva uses its own return address.
  • Content: subject, text, HTML, Reply-To and 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

  • 535 although the key is right: the user is an email address instead of the domain, or the key belongs to another domain.
  • 553: the From address 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: 250 says 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 From address 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.