Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions cmd/bounce.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,31 @@ func (a *App) BounceWebhook(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
}

// Azure ACS through Event Grid.
case service == "azure" && a.bounce.Azure != nil:
switch c.Request().Header.Get("aeg-event-type") {
// Event Grid webhook registration validation.
case "SubscriptionValidation", "SubscriptionValidationEvent":
res, err := a.bounce.Azure.ProcessSubscription(rawReq)
if err != nil {
a.log.Printf("error processing Azure Event Grid subscription validation: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
}
return c.JSONBlob(http.StatusOK, res)

// Regular event delivery.
case "", "Notification":
bs, err := a.bounce.Azure.ProcessBounce(c.Request(), rawReq)
if err != nil {
a.log.Printf("error processing Azure Event Grid notification: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
}
bounces = append(bounces, bs...)

default:
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.invalidData"))
}

// SendGrid.
case service == "sendgrid" && a.bounce.Sendgrid != nil:
var (
Expand Down
13 changes: 9 additions & 4 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ type Config struct {

BounceWebhooksEnabled bool
BounceSESEnabled bool
BounceAzureEnabled bool
BounceSendgridEnabled bool
BouncePostmarkEnabled bool
BounceForwardemailEnabled bool
Expand Down Expand Up @@ -483,6 +484,7 @@ func initConstConfig(ko *koanf.Koanf) *Config {

c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled")
c.BounceSESEnabled = ko.Bool("bounce.ses_enabled")
c.BounceAzureEnabled = ko.Bool("bounce.azure.enabled")
c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled")
c.BounceForwardemailEnabled = ko.Bool("bounce.forwardemail.enabled")
Expand Down Expand Up @@ -791,10 +793,13 @@ func initNotifs(fs stuffbin.FileSystem, i *i18n.I18n, em *email.Emailer, u *UrlC
// for incoming bounce events.
func initBounceManager(cb func(models.Bounce) error, stmt *sqlx.Stmt, lo *log.Logger, ko *koanf.Koanf) *bounce.Manager {
opt := bounce.Opt{
WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
SESEnabled: ko.Bool("bounce.ses_enabled"),
SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
SendgridKey: ko.String("bounce.sendgrid_key"),
WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
SESEnabled: ko.Bool("bounce.ses_enabled"),
AzureEnabled: ko.Bool("bounce.azure.enabled"),
AzureSharedSecret: ko.String("bounce.azure.shared_secret"),
AzureSharedSecretHeader: ko.String("bounce.azure.shared_secret_header"),
SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
SendgridKey: ko.String("bounce.sendgrid_key"),
Postmark: struct {
Enabled bool
Username string
Expand Down
4 changes: 4 additions & 0 deletions cmd/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func (a *App) GetSettings(c echo.Context) error {

s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey))
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
s.BounceAzure.SharedSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceAzure.SharedSecret))
s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password))
s.BounceForwardEmail.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceForwardEmail.Key))
s.BounceLettermint.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceLettermint.Key))
Expand Down Expand Up @@ -217,6 +218,9 @@ func (a *App) UpdateSettings(c echo.Context) error {
if set.SendgridKey == "" {
set.SendgridKey = cur.SendgridKey
}
if set.BounceAzure.SharedSecret == "" {
set.BounceAzure.SharedSecret = cur.BounceAzure.SharedSecret
}
if set.BouncePostmark.Password == "" {
set.BouncePostmark.Password = cur.BouncePostmark.Password
}
Expand Down
1 change: 1 addition & 0 deletions cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ var migList = []migFunc{
{"v5.1.0", migrations.V5_1_0},
{"v6.0.0", migrations.V6_0_0},
{"v6.1.0", migrations.V6_1_0},
{"v6.2.0", migrations.V6_2_0},
}

// upgrade upgrades the database to the current version by running SQL migration files
Expand Down
21 changes: 21 additions & 0 deletions docs/docs/content/bounces.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ listmonk supports receiving bounce webhook events from the following SMTP provid
| Endpoint | Description | More info |
|:--------------------------------------------------------------|:---------------------------------------|:----------------------------------------------------------------------------------------------------------------------|
| `https://listmonk.yoursite.com/webhooks/service/ses` | Amazon (AWS) SES | See below |
| `https://listmonk.yoursite.com/webhooks/service/azure` | Azure Communication Services (ACS) | [More info](https://learn.microsoft.com/en-us/azure/event-grid/communication-services-email-events) |
| `https://listmonk.yoursite.com/webhooks/service/sendgrid` | Sendgrid / Twilio Signed event webhook | [More info](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) |
| `https://listmonk.yoursite.com/webhooks/service/postmark` | Postmark webhook | [More info](https://postmarkapp.com/developer/webhooks/webhooks-overview) |
| `https://listmonk.yoursite.com/webhooks/service/forwardemail` | Forward Email webhook | [More info](https://forwardemail.net/en/faq#do-you-support-bounce-webhooks) |
Expand Down Expand Up @@ -91,6 +92,26 @@ If using SES as your SMTP provider, automatic bounce processing is the recommend
- Complaint: `complaint@simulator.amazonses.com`
11. You can optionally [disable email feedback forwarding](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-email.html#monitor-sending-activity-using-notifications-email-disabling).

## Azure Communication Services (ACS)

If you use Azure Communication Services Email, listmonk can receive delivery report events from Azure Event Grid and turn them into bounces.

1. In listmonk settings, go to "Bounces" and configure:
- Enable bounce processing: `Enabled`
- Enable bounce webhooks: `Enabled`
- Enable Azure ACS: `Enabled`
- Optional: set `Azure Event Grid Shared Secret`.
- Optional: set `Azure Shared Secret Header Name` if you want listmonk to read the secret from a header (defaults to `X-Listmonk-Webhook-Secret`).
2. In listmonk settings, go to "SMTP" and use the `Azure ACS` quick preset to fill SMTP defaults.
3. In Azure, create an Event Grid subscription for your ACS Email events with:
- Endpoint type: `Web Hook`
- Endpoint URL: `https://listmonk.yoursite.com/webhooks/service/azure`
- If using query-param auth, append `?code=<your-shared-secret>`.
- If using header auth, configure Event Grid to include the same secret in the header name configured in listmonk.
4. During subscription creation, Event Grid sends a subscription validation event. listmonk automatically returns `validationResponse` for this handshake.
5. Subscribe to `Microsoft.Communication.EmailDeliveryReportReceived` events. listmonk maps relevant statuses to bounce records.
6. Send test mail and verify bounces in listmonk.

## Exporting bounces

Bounces can be exported via the JSON API:
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/content/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ When configuring auth proxies and web application firewalls, use this table.
| `GET, ` | `/link/*` | Tracked link redirection |
| `GET` | `/campaign/*` | Pixel tracking image |
| `GET` | `/public/*` | Static files for HTML subscription pages |
| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid |
| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for SES, Azure ACS, Sendgrid, and other supported providers |
| `GET` | `/uploads/*` | The file upload path configured in media settings |


Expand Down
16 changes: 16 additions & 0 deletions docs/swagger/collections.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2844,6 +2844,8 @@ components:
type: string
settings.bounces.enableSES:
type: string
settings.bounces.enableAzure:
type: string
settings.bounces.enableSendgrid:
type: string
settings.bounces.enableForwardemail:
Expand All @@ -2860,6 +2862,14 @@ components:
type: string
settings.bounces.invalidScanInterval:
type: string
settings.bounces.azureSharedSecret:
type: string
settings.bounces.azureSharedSecretHelp:
type: string
settings.bounces.azureSharedSecretHeader:
type: string
settings.bounces.azureSharedSecretHeaderHelp:
type: string
settings.bounces.name:
type: string
settings.bounces.scanInterval:
Expand Down Expand Up @@ -3553,6 +3563,12 @@ components:
type: string
bounce.ses_enabled:
type: boolean
bounce.azure.enabled:
type: boolean
bounce.azure.shared_secret:
type: string
bounce.azure.shared_secret_header:
type: string
bounce.sendgrid_enabled:
type: boolean
bounce.sendgrid_key:
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ export default Vue.extend({
hasDummy = 'sendgrid';
}

if (this.isDummy(form['bounce.azure'].shared_secret)) {
form['bounce.azure'].shared_secret = '';
} else if (this.hasDummy(form['bounce.azure'].shared_secret)) {
hasDummy = 'azure shared secret';
}

if (this.isDummy(form['security.captcha'].hcaptcha.secret)) {
form['security.captcha'].hcaptcha.secret = '';
} else if (this.hasDummy(form['security.captcha'].hcaptcha.secret)) {
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/views/settings/bounces.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enableAzure')">
<b-switch v-model="data['bounce.azure'].enabled" name="azure_enabled" :native-value="true"
data-cy="btn-enable-bounce-azure" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.azureSharedSecret')" :message="$t('settings.bounces.azureSharedSecretHelp')">
<b-input v-model="data['bounce.azure'].shared_secret" type="password"
:disabled="!data['bounce.azure'].enabled" name="azure_shared_secret"
data-cy="bounce-azure-shared-secret" />
</b-field>
</div>
<div class="column">
<b-field :label="$t('settings.bounces.azureSharedSecretHeader')" :message="$t('settings.bounces.azureSharedSecretHeaderHelp')">
<b-input v-model="data['bounce.azure'].shared_secret_header" type="text"
:disabled="!data['bounce.azure'].enabled" name="azure_shared_secret_header"
data-cy="bounce-azure-shared-secret-header" />
</b-field>
</div>
</div>
<div class="columns">
<div class="column is-3">
<b-field :label="$t('settings.bounces.enableSendgrid')">
Expand Down
5 changes: 5 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@
"settings.bounces.enableMailbox": "Enable bounce mailbox",
"settings.bounces.enablePostmark": "Enable Postmark",
"settings.bounces.enableSES": "Enable SES",
"settings.bounces.enableAzure": "Enable Azure ACS",
"settings.bounces.enableSendgrid": "Enable SendGrid",
"settings.bounces.enableWebhooks": "Enable bounce webhooks",
"settings.bounces.enabled": "Enabled",
Expand All @@ -411,6 +412,10 @@
"settings.bounces.scanInterval": "Scan interval",
"settings.bounces.scanIntervalHelp": "Interval at which the bounce mailbox should be scanned for bounces (s for second, m for minute).",
"settings.bounces.sendgridKey": "SendGrid Key",
"settings.bounces.azureSharedSecret": "Azure Event Grid Shared Secret",
"settings.bounces.azureSharedSecretHelp": "Provide the shared secret configured for your Azure Event Grid webhook endpoint. listmonk accepts the secret via query parameter ?code=... or via the configured header name.",
"settings.bounces.azureSharedSecretHeader": "Azure Shared Secret Header Name",
"settings.bounces.azureSharedSecretHeaderHelp": "Optional HTTP header name to read the Azure shared secret from. If empty, listmonk uses X-Listmonk-Webhook-Secret.",
"settings.bounces.type": "Type",
"settings.bounces.username": "Username",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
Expand Down
23 changes: 15 additions & 8 deletions internal/bounce/bounce.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ type Mailbox interface {

// Opt represents bounce processing options.
type Opt struct {
MailboxEnabled bool `json:"mailbox_enabled"`
MailboxType string `json:"mailbox_type"`
Mailbox mailbox.Opt `json:"mailbox"`
WebhooksEnabled bool `json:"webhooks_enabled"`
SESEnabled bool `json:"ses_enabled"`
SendgridEnabled bool `json:"sendgrid_enabled"`
SendgridKey string `json:"sendgrid_key"`
Postmark struct {
MailboxEnabled bool `json:"mailbox_enabled"`
MailboxType string `json:"mailbox_type"`
Mailbox mailbox.Opt `json:"mailbox"`
WebhooksEnabled bool `json:"webhooks_enabled"`
SESEnabled bool `json:"ses_enabled"`
AzureEnabled bool `json:"azure_enabled"`
AzureSharedSecret string `json:"azure_shared_secret"`
AzureSharedSecretHeader string `json:"azure_shared_secret_header"`
SendgridEnabled bool `json:"sendgrid_enabled"`
SendgridKey string `json:"sendgrid_key"`
Postmark struct {
Enabled bool
Username string
Password string
Expand All @@ -48,6 +51,7 @@ type Manager struct {
queue chan models.Bounce
mailbox Mailbox
SES *webhooks.SES
Azure *webhooks.Azure
Sendgrid *webhooks.Sendgrid
Postmark *webhooks.Postmark
Forwardemail *webhooks.Forwardemail
Expand Down Expand Up @@ -86,6 +90,9 @@ func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) {
if opt.SESEnabled {
m.SES = webhooks.NewSES()
}
if opt.AzureEnabled {
m.Azure = webhooks.NewAzure(opt.AzureSharedSecret, opt.AzureSharedSecretHeader)
}

if opt.SendgridEnabled {
sg, err := webhooks.NewSendgrid(opt.SendgridKey)
Expand Down
Loading