Skip to content

Send mail from this installation

Most failures on this platform are visible: a build breaks, a container restarts, a page answers 500.

Mail is different. A message that was never delivered looks the same, from your side, as one that arrived. Nothing errors. You find out weeks later, when someone tells you their invitation never came.

For that reason Flui treats mail as a capability with its own state: which provider carries which kind of mail, which domains you may send from, what the receiving servers said, and which addresses you have stopped writing to. This page covers that whole loop.

Two senders, kept apart

Flui asks you to make this decision up front, because it is easy to get wrong.

There are two kinds of mail, and each has its own connection:

  • transactional: one message to one person, because that person did something. A password reset, an invitation, a receipt.
  • bulk: one message to many people. A newsletter, an announcement.

They never share an account. If a mailing list draws a complaint and the provider suspends you, the suspension also stops your password resets. With separate accounts, a problem with the newsletter stays with the newsletter.

Providers also have a say. Most transactional providers forbid one-to-many mail in their terms. Flui refuses a bulk send through a provider whose capabilities do not include it, so you do not find out when the account is closed.

The four providers

ProviderCarriesCredential
Scaleway Transactional Emailtransactionalnone (it reuses the Scaleway key Flui already holds for compute)
Brevotransactional and bulkan API key
ZeptoMailtransactionala send-mail token, plus its regional API host
SMTP relaywhatever you declare it may carryhost, port and a password

ZeptoMail and the SMTP relay are written and tested, but neither has been observed delivering a real message to a real inbox. The dashboard does not offer them. The CLI still connects them, with a warning first, because the CLI is the tool you would use to prove one works. If the CLI refused too, neither could ever be proven.

Connect a provider

One command per provider. When the installation already holds a Scaleway compute credential, Scaleway needs only a domain, because the key is already here. Otherwise it asks for one:

Terminal window
flui mail connect scaleway-tem --domain mail.example.com

Any other provider needs a credential, and the command asks for it at a prompt instead of taking it as an argument. Before the prompt, it prints where to get the credential (the exact path through that provider’s console) and what to watch out for:

Terminal window
flui mail connect brevo --scope bulk --domain news.example.com

The prompt does not echo, and nothing lands in your shell history. Pass --stdin to pipe the credential in from a password manager instead.

A --secret flag also exists, for scripts. The examples avoid it: a flag lands in your shell history, and every other account on the machine can see it while the command runs.

An SMTP relay knows nothing about itself, so you supply everything:

Terminal window
flui mail connect smtp \
--host smtp.example.com --port 587 \
--username postmaster --allow-bulk

--allow-bulk is a declaration, never a guess, because only you know which relay this points at. Without it, bulk sends through the relay are refused.

Other flags:

FlagWhat it does
--scope transactional|bulkWhat this sender carries. Defaults to transactional.
--domain <domain>The sending domain. Flui registers it and publishes what it can.
--activateStart sending through this one now. Without it, a new connection only takes the scope if nothing else holds it.
--label <name>What you call it.
--region <host>ZeptoMail only, and required. The regional API host is also where the account’s data lives, so Flui never guesses it.
--spf-include, --dkim-selector, --dkim-valueSMTP only, copied verbatim from the relay’s setup page.

The command converges: running it again fixes what is missing instead of creating a second connection.

When it finishes, it reports three things separately: what was connected, whether it is carrying mail yet, and what you still need to do by hand.

To see the state later, and to switch:

Terminal window
flui mail providers # who carries what
flui mail use <id> # move a scope onto another one
flui mail disconnect <id> --yes

flui mail providers also lists connections that are configured but not sending, under standby, so you do not end up pasting the same API key in twice.

use is reversible: the provider you switch away from keeps its credential.

disconnect destroys the stored credential, so it requires --yes. Flui first polls the provider one last time, so a bounce still in flight is not lost. The domain stays registered at the provider either way.

Register a sending domain

You cannot send from a domain you have not proved you own. The provider needs records published in your DNS (SPF and DKIM at minimum), and it re-reads them on its own schedule before it accepts mail from you.

Terminal window
flui mail publish example.com

This does two jobs: it registers the domain with the provider, and it publishes the records the provider asked for.

Where Flui holds the zone, it writes the records for you. Where it does not, they come back as an outstanding list (name, kind and value) for you to publish wherever the zone lives.

MX and SPF records get special handling. Flui writes an MX only into a domain that has none, because overwriting an existing one would silently swallow a working inbox. SPF is merged, not replaced: Flui adds its include: to the existing record, and refuses when the domain publishes more than one. Every other record replaces whatever shares its name.

Verification comes from the provider and lags DNS by minutes, and the command tells you so. Check with flui mail readiness --domain example.com. You can run publish again as often as you like, because it converges.

To hand a domain back:

Terminal window
flui mail remove old.example.com --yes

This is permanent. The DKIM private key is destroyed with it, so registering the same domain again issues a new key under a new selector. The DNS cleanup is partial. The DKIM record is removed and Flui’s own SPF include is taken out, while the MX and DMARC records are named in the output and left in place: nothing recorded whether Flui created them, and deleting an MX that Flui did not create stops inbound mail with no error anywhere. --keep-dns revokes the domain at the provider and leaves the zone untouched.

Ask what is still missing

When mail does not arrive, several different causes look identical from outside: a credential that does not cover the provider’s email service, a domain registered under a different account, records still propagating, or a domain the provider has not accepted yet. Each one needs a different fix.

So Flui does not infer the reason from a failed send. It asks the provider and reports four ordered steps on Scaleway and Brevo. ZeptoMail and SMTP report only the first, the credential:

StepThe question it answers
credentialDoes the connected key reach the provider’s email service at all?
domainIs this sending domain registered there?
dnsAre SPF and DKIM published, and correct?
verificationHas the provider accepted the domain?

Each step comes back in one of four states:

  • satisfied: done.
  • Flui can do this: nothing blocks the step; it is waiting for a command.
  • needs you: carries the exact action, and a link to the provider’s console where you take it.
  • in progress at the provider: not an error. The provider is still working, so wait.
Terminal window
flui mail readiness
flui mail readiness --domain example.com

Without a domain it checks only the credential, which is enough to tell whether email is set up here at all.

It asks the provider that is currently carrying transactional mail, not whichever one was connected first. A readiness report about an account that is not sending would mislead you.

Prove it, before you depend on it

A green readiness report says the records are correct. It does not say that a message arrives. To know that, you have to send one.

There are two proofs:

  • A delivery test sends a real message to an address you can open. By default that is your own address, because it is the only mailbox you can check and the only one Flui should write to without being asked.
  • A bounce probe sends to a subdomain of your own domain that does not exist. A real refusal then travels the whole path (provider, poller, activity list) and you watch it arrive. This tests Flui’s side of the path rather than the provider.
Terminal window
flui mail test example.com
flui mail test example.com --to me@elsewhere.test
flui mail test example.com --bounce

Before anything is sent, the command prints the sender, the recipient and the subject, so mail never goes somewhere you did not see.

--connection <id> probes one provider instead of a domain. It is the only way to test a bulk sender, or a provider that is configured but not yet carrying anything.

Run the bounce probe a second time and the command reports that nothing was sent, which is the expected result. The address went onto the do-not-send list during the first probe, so Flui refused before dialling. That shows the suppression list working, and a second run is the only way to see it. Clear the address with flui mail unsuppress when you want to run the probe again.

Send a message

Terminal window
flui mail send \
--from noreply@example.com \
--to someone@example.net \
--subject "Hello" \
--text "Hi there"

--to repeats for more than one recipient. Use --html instead of, or alongside, --text; one of the two is required. --reference attaches your own correlation id to the message. Use it for an id, never for content.

--scope defaults to transactional. If you ask for bulk through a provider whose terms forbid it, the message is refused here, before it can lead to a suspension.

The result says accepted, and the command reminds you that this means the provider took the message, not that it arrived. The verdict comes later, from the receiving server, and you read it in the delivery events.

Read what happened

Flui stores delivery outcomes as state, not as a log. A message’s verdict changes as it progresses: queued, then sent, then delivered, or bounced an hour later. Flui keeps one row per message and recipient and updates it, so you do not have to fold a log yourself.

Each outcome carries the recipient, the subject, the SMTP status code, and the receiving server’s own words, quoted verbatim:

550 5.7.1 SPF check failed for example.com

That line is usually the only text on the platform that explains why a message failed, so Flui never paraphrases it.

What each provider can tell you

Providers differ a lot here. If a provider never confirms delivery, you cannot ask it whether mail arrived, however good its console looks.

ProviderHow outcomes arriveWhat you get
ScalewayFlui pollsThe fullest set: queued, sent, delivered, bounced, complained, canceled
BrevoBrevo pushes to FluiThe full vocabulary, including complaints and unsubscribes. The only one that can feed a bulk suppression list properly
ZeptoMailZeptoMail pushes to FluiFailures only. Delivery is never confirmed: no failure means accepted, which is a weaker claim
SMTP relayNothing arrivesOnly the addresses the relay refuses at handover. Later failures come back as bounce messages to a mailbox Flui does not read

Flui tells you about a provider’s blind spot once, when you connect it, so you do not discover it later from a console that looks empty.

Terminal window
flui mail events
flui mail events --since 2026-08-11T00:00:00Z
flui mail events --message-id 4f2c…

--since takes an ISO timestamp and compares it with the time of each message’s latest state, so a message sent this morning that bounces this afternoon is found under the afternoon. The list shows the latest 50.

For a summary of everything:

Terminal window
flui mail overview
flui mail overview --window 24h

This shows an incident line if there is one, the headline rates, every sending domain with its proofs, and each sending address with how it is doing. Windows are 24h, 7d, 14d (the default) and 30d. The preceding window of the same length is the baseline for the change figures. If the baseline window holds no mail, no change figure is shown.

The do-not-send list

When an address bounces permanently, someone reports you as spam, or someone unsubscribes, Flui stops writing to that address. You can read the list. A do-not-send list nobody can see is one nobody can trust, and the application that thinks it sent something has no way to see it.

Each entry has a reach:

  • everything: a mailbox that does not exist is missing for every kind of mail, and so is someone who reported you as spam. Nothing is delivered.
  • bulk only: someone who left a mailing list may still be waiting for the password reset they asked for thirty seconds ago. Their transactional mail keeps arriving.

Treating the two the same way would lock people out of their own accounts without anyone noticing.

Terminal window
flui mail suppressions
flui mail suppressions --scope bulk

Each entry shows why it was suppressed, when, and how far the stop reaches.

To release an address:

Terminal window
flui mail unsuppress someone@example.com

A mailbox that was full in March can work again in April, and a list with no way off would eventually suppress your own address. Releasing only clears the record and says nothing about the mailbox. If the address bounces again, it is suppressed again.

Seeing mail is its own permission

Recipient addresses are other people’s personal data, and that shapes who can reach any of this.

On the agent surface, mail is its own permission area, next to applications and logs rather than inside them. The group is called See mail delivery. It is read-only, and there is no deeper mail group, because no scope on the instance lets an agent write mail.

Giving an agent your logs does not give it everyone’s email. That needs its own approval.

The one exception is mail_domain_publish, which is filed under Operate the infrastructure because it writes DNS records, and which asks you first.

On the dashboard and the CLI, the whole Mail section is gated, not only the writes. It needs authority over the whole installation, held globally rather than limited to one cluster or one set of applications. That puts it at the same level as providers, backups and cluster firewalls.

More on how those groups are chosen: Choose what your agent may do.

Flui sends its own mail too

The API can also mail an invitation link when asked to, and the link is produced either way. If no sender is configured here, the invitation does not fail: the link comes back for you to pass on by hand, with the reason it was not sent. A misconfigured mailer never costs you the invite link.

Where this goes next