diff --git a/docs/extend/mail.md b/docs/extend/mail.md index 24ca0db75..981bb948e 100644 --- a/docs/extend/mail.md +++ b/docs/extend/mail.md @@ -91,3 +91,93 @@ return [ ``` The identifier (`'custom'` above) is what an administrator selects on the admin mail page to activate your driver. + +## Bounces and Spam Complaints + +From Flarum 2.1, a driver can tell Flarum when mail to an address bounces permanently or is marked as spam. A provider reports these through a webhook, minutes or hours after it accepted the message, so a driver opts in by implementing `Flarum\Mail\HandlesWebhooks`: + +- **`getWebhookRequestParser(SettingsRepositoryInterface $settings): RequestParserInterface`**: a [Symfony Webhook](https://symfony.com/doc/current/webhook.html) request parser for the provider. The parser verifies the request, whether by signature, IP address or anything else the provider uses, and turns it into Symfony remote events. Many `symfony/*-mailer` bridges ship one. +- **`getWebhookSigningSecret(SettingsRepositoryInterface $settings): ?string`**: the secret passed to the parser, or `null` when the provider does not use one. +- **`webhookConfigured(SettingsRepositoryInterface $settings): bool`**: whether webhooks can be trusted with the current settings. Return `false` while a setting the parser needs, such as a signing key, is missing. Flarum then warns the admin that bounce handling isn't set up. +- **`webhooksAvailable(SettingsRepositoryInterface $settings): bool`**: whether the driver can report bounces at all with the current settings. Return `false` for a configuration that never delivers to real recipients, such as a sandbox mode. Flarum then hides bounce handling entirely. +- **`isPermanentFailure(MailerDeliveryEvent $event): bool`**: whether a bounce or drop the parser reported is permanent. Flarum only flags the recipient when it is. + +Flarum receives webhooks at `/mail/webhook/{driver}`, where `{driver}` is the identifier the driver was [registered](#registering-the-driver) with. Requests are refused with a 404 unless that driver is the active one, its webhooks are available, and `webhookConfigured()` returns `true`. A rejection the parser throws is answered with its status code. Every refused request is logged as a warning, with the reason and the sender's IP address. A parser may also return `null` for a request it accepted but that carries nothing to act on. + +Flarum acts on two kinds of event: + +- A `MailerDeliveryEvent` named `bounce` or `dropped` dispatches `Flarum\Mail\Event\EmailBounced`, when `isPermanentFailure()` returns `true`. +- A `MailerEngagementEvent` named `spam` dispatches `Flarum\Mail\Event\EmailComplained`. + +Every other event is ignored. Many of Symfony's converters report soft bounces, and even auto-replies, as `bounce`, so `isPermanentFailure()` should check the provider's own payload. Here are the methods core's Postmark driver adds. Postmark verifies webhooks by IP address, so there is no secret, and it marks a bounce `Inactive` when it stops sending to the address: + +```php +use Flarum\Mail\DriverInterface; +use Flarum\Mail\HandlesWebhooks; +use Flarum\Settings\SettingsRepositoryInterface; +use Symfony\Component\Mailer\Bridge\Postmark\RemoteEvent\PostmarkPayloadConverter; +use Symfony\Component\Mailer\Bridge\Postmark\Webhook\PostmarkRequestParser; +use Symfony\Component\RemoteEvent\Event\Mailer\MailerDeliveryEvent; +use Symfony\Component\Webhook\Client\RequestParserInterface; + +class PostmarkDriver implements DriverInterface, HandlesWebhooks +{ + // ... + + public function getWebhookRequestParser(SettingsRepositoryInterface $settings): RequestParserInterface + { + return new PostmarkRequestParser(new PostmarkPayloadConverter()); + } + + public function getWebhookSigningSecret(SettingsRepositoryInterface $settings): ?string + { + return null; + } + + public function webhookConfigured(SettingsRepositoryInterface $settings): bool + { + return true; + } + + public function webhooksAvailable(SettingsRepositoryInterface $settings): bool + { + return true; + } + + public function isPermanentFailure(MailerDeliveryEvent $event): bool + { + return ($event->getPayload()['Inactive'] ?? false) === true; + } +} +``` + +A provider that signs its webhooks instead, like Mailgun, adds a setting for the signing key, returns it from `getWebhookSigningSecret()`, and returns `false` from `webhookConfigured()` until it is set. + +### Registering the webhook with the provider + +If the provider's API can register webhooks, also implement `Flarum\Mail\ProvisionsWebhooks`. The admin mail page then shows buttons that call it with the forum's webhook URL. Without it, the admin adds the URL, shown on the same page, in the provider's dashboard. + +- **`registerWebhook(SettingsRepositoryInterface $settings, string $url): void`**: register `$url` for permanent failures and spam complaints. Calling it again must change nothing, and it must keep any URLs that other applications registered with the same account. +- **`unregisterWebhook(SettingsRepositoryInterface $settings, string $url): void`**: remove `$url`, and only `$url`. + +If the forum is behind HTTP basic authentication, the admin can enter a username and password on the mail page, and they are included in `$url` as `https://username:password@...`. When the admin changes them, `$url` changes too, so match an existing registration by the rest of the URL and replace it, rather than adding a second one. + +Throw an exception when the provider refuses. Its message is shown to the admin. + +### Listening for bounces + +`EmailBounced` and `EmailComplained` carry the address (`$email`), the provider's reason (`$reason`), the matching user if there is one (`$recipient`), and the provider's payload (`$raw`). Core records each one, flags the user, and can stop sending them notification emails. You can [listen](backend-events.md) for them too, for example to report bounces somewhere else: + +```php +use Flarum\Extend; +use Flarum\Mail\Event\EmailBounced; + +return [ + (new Extend\Event()) + ->listen(EmailBounced::class, function (EmailBounced $event) { + // $event->email, $event->reason, $event->recipient + }), +]; +``` + +`$user->hasBouncedEmail()` tells you whether a user's address is currently flagged. The flag clears when the user changes their email address. diff --git a/docs/extend/update-2_x.md b/docs/extend/update-2_x.md index c022749de..afe3502aa 100644 --- a/docs/extend/update-2_x.md +++ b/docs/extend/update-2_x.md @@ -8,3 +8,7 @@ If you need help applying these changes or using new features, please start a di ## 2.1 Changes +### Mail Bounces + +Flarum can now find out when mail to an address bounces permanently or is marked as spam. A mail driver opts in by implementing `Flarum\Mail\HandlesWebhooks`, and optionally `Flarum\Mail\ProvisionsWebhooks` to register the webhook for the admin. Core's Mailgun and Postmark drivers implement them. Extensions can listen for the new `Flarum\Mail\Event\EmailBounced` and `EmailComplained` events, and check `$user->hasBouncedEmail()`. See [Bounces and Spam Complaints](mail.md#bounces-and-spam-complaints). + diff --git a/docs/mail.md b/docs/mail.md index 3f354df6f..c41022c15 100644 --- a/docs/mail.md +++ b/docs/mail.md @@ -20,10 +20,31 @@ This driver uses your [Mailgun](https://www.mailgun.com/) account to send emails To use the mailgun driver, you'll need to install the Guzzle composer package (a PHP HTTP client). You can do this by running `composer require guzzlehttp/guzzle:^6.0|^7.0` in your Flarum install's root directory. +### Postmark + +This driver uses your [Postmark](https://postmarkapp.com/) account to send emails. You'll need a server token, and optionally the message stream to send through. + ### Log The log mail driver DOES NOT SEND MAIL, and is primarily used by developers. It writes the content of any emails to the log file in `FLARUM_ROOT_DIRECTORY/storage/logs`. +## Bounce Handling + +When mail to an address bounces permanently, or a recipient marks it as spam, sending more mail to that address harms your forum's reputation with mail providers. From Flarum 2.1, the Mailgun and Postmark drivers can have your provider report these back to Flarum, which flags the affected users. Other drivers, like SMTP, have no way to report them. + +The Mail page shows a Bounce Handling section when the selected driver supports it: + +- **Mailgun:** paste the HTTP webhook signing key from your Mailgun dashboard into the "Webhook signing key" field and save. Then click "Register webhook with provider". Flarum adds its webhook for permanent failures and spam complaints, and keeps any webhooks other applications have registered. Mailgun allows three URLs per event, so if three are already registered, remove one in Mailgun first. "Remove webhook from provider" takes Flarum's webhook off again. +- **Postmark:** copy the "Webhook URL" shown on the Mail page. In Postmark, add a webhook with that URL to the message stream your forum sends through, with bounce and spam complaint events selected. Flarum only counts a bounce when Postmark deactivates the address, so soft bounces and out-of-office replies are ignored. + +If your forum is behind HTTP basic authentication, for example a protected staging site, enter a username and password under "Webhook username" and "Webhook password" before registering or copying the webhook URL. They are added to the URL so your mail provider can get through. + +Flarum logs every webhook it refuses, with the reason and the sender's IP address. If bounces don't arrive, check your forum's log. Postmark is checked against its published IP addresses, which Flarum reads from the connection. If your forum is behind a proxy or CDN, your web server must pass the visitor's real IP address to PHP, for example with Apache's `mod_remoteip` or nginx's `real_ip` module, or every Postmark webhook is refused. + +A flagged user sees a notice asking them to update their email address, and the flag clears once they do. Admins can see who is affected in the "Email delivery" column of the Users page, or by searching for `is:bounced`. The dashboard's Email Bounces widget counts bounces over time. + +By default Flarum keeps emailing flagged users. Turn on "Stop emailing bounced addresses" to skip their notification emails. Account emails, such as email confirmations and password resets, are still sent. + ## Testing Email Once you've saved an email configuration, you can click the "Send Test Mail" button on the Mail page of the admin dashboard to make sure your configuration works. If you see an error, or do not receive an email, adjust the configuration and try again. Make sure to check your spam if there's no error, but nothing shows up in your inbox.