Skip to content
axxbetadocs
nextv0.2.26
GitHub
These are the docs of main, not released yet. See the docs of v0.2.26, the latest release.

Check emails

View .mdOpen in Claude

Services email people: a label to a shop, a delivery notice, a password reset. Your service sends its mail as it does in production, by SMTP, to the mail server of your test environment, and the mail pack reads the mailbox that catches it.

Scenario: A shop gets its parcel's label by email
Given a seeds/mail-registered.yaml db seed
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property reference is 'PX-MAIL-9701'
And the request payload property sender is 'juniper-and-jay'
When the request is executed
Then the response status code is 201
And within 10s the shops mailbox has an email where:
| to | orders@juniper-and-jay.example |
| subject | Parcel PX-MAIL-9701 registered |
| attachment | PX-MAIL-9701-label.zpl |

Add the pack to the project with axx pack add mail (Choose packs).

How your service sends its mail decides where a test finds it:

Your service sends In the tests The check
by SMTP, to a relay or a provider’s SMTP endpoint a mail server that catches it: smtp4dev, which can also stub refusals, or Mailpit, GreenMail, Inbucket a mailbox with an imap:// or pop3:// url, or Mailpit’s, read through its API
by SMTP, and it must really arrive a real inbox, in a staging environment a mailbox with an imaps:// url
through a provider’s HTTP API (SendGrid, SES, Mailgun, Postmark, Resend) WireMock, against the provider’s contract the mock pack: the request’s payload properties

Point your service’s SMTP settings at the mail server of the test environment, as the parcels example does in its Compose file:

compose.yaml
services:
mail:
image: rnwood/smtp4dev:3.15.0
ports: ['1025:25', '1143:143', '8025:80'] # SMTP, IMAP, its web interface
app:
environment:
PARCELS_SMTP_ADDR: mail:25
Background:
Given the shops mailbox with the following properties:
| url | imap://${sys:local.host}:1143 |
| username | axx |
| password | axx |
  • url: Mailpit’s address (http:// or https://), or pop3://, pop3s://, imap:// or imaps:// and the mail server’s host.
  • username and password: sign in over POP3 and IMAP, or to a Mailpit behind a password. The password is masked.
  • folder: the IMAP folder, INBOX unless it says otherwise.

smtp4dev takes any username and password over IMAP, and its INBOX has every email it received. Mailpit answers POP3 once it is given a password file (--pop3-auth-file, or MP_POP3_AUTH).

Then within 10s the shops mailbox has an email where:
| to | orders@larkspur-lane.example |
| subject | Parcel PX-MAIL-9702 delivered |
| html | <b>PX-MAIL-9702</b> was delivered in Leipzig |
Row What it checks
to, cc an address the email went to, or was copied to
from the address it came from
subject its subject, all of it
text text its plain-text body contains
html text its HTML body contains, tags and all
attachment the file name of an attachment
header <name> a header’s value

Addresses compare without regard to case, and text and html take runs of spaces and line breaks as one space. Subjects and names in other charsets are decoded first. The check waits for an email that meets every row: 10 seconds, or the time within gives. When none does, it lists the emails that arrived.

What does your service do when a mail server refuses its email? Stub the refusal in the mail server, as you stub an HTTP dependency’s failures in WireMock’s mappings: by the data a scenario uses. smtp4dev runs a JavaScript expression for each recipient, and error(code, message) refuses it with that SMTP code:

compose.yaml
services:
mail:
image: rnwood/smtp4dev:3.15.0
environment:
# quince-and-quill's mail server is busy: it asks to be tried again later.
ServerOptions__RecipientValidationExpression: >-
recipient.toLowerCase().endsWith("@quince-and-quill.example")
? error(451, "Mailbox busy, try again later") : true

A scenario whose shop is quince-and-quill then sees how your service copes:

Scenario: A registration email the shop's mail server refuses does not stop the registration
Given a seeds/mail-refused.yaml db seed
And a POST request to /api/parcels
And a request payload using an application/json content example
And the request payload property sender is 'quince-and-quill'
When the request is executed
Then the response status code is 201
  • A 4xx code, like 451, is a failure the sender should retry; a 5xx, like 550, one it should not.
  • The stub refuses only the recipients it names, so scenarios running beside it are not affected: give each refusal a recipient of its own.
  • CommandValidationExpression refuses at other stages, such as the sender’s mail from: (smtp4dev passes the command’s verb in lowercase), and MessageValidationExpression refuses whole messages.

A check only looks at the emails that arrived since its scenario started, and axx never deletes or marks what it reads. Scenarios running in parallel share the mailbox, so each checks its own mail by a recipient or a subject unique to it, such as a shop or a parcel reference of its own.

For a run, axx reads each mailbox the checks name from when the services are up:

  • Mailpit and IMAP say when each email arrived.
  • POP3 doesn’t, so axx takes what the mailbox held when it started reading as old.

See the mail pack’s reference for every step.