Outgoing mail goes through Resend. Replies can come back in and land in the thread they came from.
Every value here is configuration, because every installation runs on its own domain with its own Resend account.
Outgoing
MAIL_MAILER=resend
RESEND_KEY=re_...
MAIL_FROM_ADDRESS="felagi@yourdomain.com"
MAIL_FROM_NAME="${APP_NAME}"
The variable is RESEND_KEY, not RESEND_API_KEY — that is the name
config/services.php reads. An .env carrying the other one looks configured
and sends nothing.
Local development leaves MAIL_MAILER=log and reads mail out of
storage/logs/laravel.log.
A missing key does not take the site down. If the mailer is resend and no
key is set, Félagi falls back to the log driver and writes a critical line
saying so. A deployment without its mail key should keep serving and keep the
messages readable, rather than throw transport errors at people trying to
register.
Auth mail and comment notifications are queued on the mail lane, so a form
submission never waits on somebody else's API:
php artisan queue:work --queue=broadcasts,mail,default
Incoming
Off by default. Switch it on only once a domain's MX records actually point at Resend — an advertised reply address that bounces is worse than none.
FELAGI_INBOUND_ENABLED=true
FELAGI_INBOUND_DOMAIN=mail.yourdomain.com
FELAGI_INBOUND_LOCAL_PART=reply
FELAGI_INBOUND_EVENT=email.received
RESEND_WEBHOOK_SECRET=whsec_...
Point the Resend webhook at:
POST https://yourdomain.com/hooks/resend
A subdomain is the usual choice for FELAGI_INBOUND_DOMAIN, so ordinary mail to
the main domain is untouched.
FELAGI_INBOUND_EVENT exists because the event name belongs to Resend rather
than to us. If replies are not arriving, the log will contain the event type
that did arrive — set the variable to match.
How a reply finds its issue
Notifications carry a Reply-To cut for one recipient and one issue:
reply+42.7.cbe3b7e4dee4f20b@mail.yourdomain.com
│ │ └── HMAC of the pair, keyed on APP_KEY
│ └───── user
└──────── issue
The signature is the point. Without it, anyone who saw one address could work out every other one and post comments in a colleague's name — a forwarded notification would be enough. It is checked in constant time, before either record is loaded.
The address is short by necessity: RFC 5321 caps a local part at 64 characters, so an encrypted payload does not fit.
The webhook does not contain the email
Resend's email.received event carries metadata only — sender, recipients,
subject, attachment list — so that large messages do not have to fit inside a
webhook request. The body, headers and attachments come from a second call to
the Receiving API, keyed on data.email_id.
That shapes the flow here:
- Verify the signature
- Read the recipients from
to,ccandreceived_for - Match one against a reply address — before fetching anything
- Queue the fetch, and answer
202
Step 3 matters because every address at the receiving domain reaches this endpoint. Matching first means spam costs no API call. Step 4 matters because holding a webhook open for somebody else's API is how endpoints time out, and a timeout looks like a rejection to the sender.
received_for matters as much as to: a forwarded reply arrives addressed to
the forwarding mailbox, and the address we cut is only in the Received headers.
What happens to a reply
- Quoted history is cut off —
>lines,On … wrote:, Outlook's divider - The remainder becomes a comment, sanitised on write like any other
- Mentioning an agent works exactly as it does in the interface:
@Freyaqueues a run - Out-of-office replies are dropped. RFC 3834 exists so this is detectable, and
our own notifications carry
Auto-Submitted: auto-generatedso the loop cannot start from the other side either
Security
The endpoint is public, so the signature is all that stands between it and
somebody writing comments in your workspace. Verification is delegated to
resend/resend-php rather than hand-rolled.
- Svix scheme: HMAC-SHA256 over
{id}.{timestamp}.{body}, verified against the raw request body — a round trip throughjson_decodechanges the bytes and breaks the signature - Several signatures are accepted during a secret rotation
- Signatures older than
RESEND_WEBHOOK_TOLERANCE(default 300s) are refused, so a captured request cannot be replayed tomorrow - No secret configured means every request is refused. A webhook that stops working is a better failure than one that accepts anything
Known gaps
- Bounces and complaints are logged, not acted on. Resend keeps its own suppression list, but Félagi does not read it, so a dead address keeps being mailed.
- Attachments are ignored. Resend exposes them through a separate API and nothing here fetches them yet.
- There is no digest or notification preference. Every comment on an issue you are part of sends an email.