From b5ad57c0ec491f6b49f438dd6afb862eedba6ddc Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 16 Sep 2026 08:20:59 +0200 Subject: [PATCH 1/2] Handle Postmark webhooks to suppress addresses --- config/.env.test | 2 + config/runtime.exs | 7 + .../controllers/api/postmark_controller.ex | 90 ++++++++++ lib/plausible_web/router.ex | 2 + .../api/postmark_controller_test.exs | 155 ++++++++++++++++++ 5 files changed, 256 insertions(+) create mode 100644 lib/plausible_web/controllers/api/postmark_controller.ex create mode 100644 test/plausible_web/controllers/api/postmark_controller_test.exs diff --git a/config/.env.test b/config/.env.test index 354c0bffdb91..909c9bd18f61 100644 --- a/config/.env.test +++ b/config/.env.test @@ -19,6 +19,8 @@ HELP_SCOUT_APP_ID=fake_app_id HELP_SCOUT_APP_SECRET=fake_app_secret HELP_SCOUT_SIGNATURE_KEY=fake_signature_key HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM= +POSTMARK_WEBHOOK_USERNAME=fake_webhook_username +POSTMARK_WEBHOOK_PASSWORD=fake_webhook_password S3_DISABLED=false S3_ACCESS_KEY_ID=minioadmin diff --git a/config/runtime.exs b/config/runtime.exs index a723cc361c44..c080d6101592 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -257,6 +257,8 @@ paddle_vendor_id = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_ID") google_cid = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_ID") google_secret = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_SECRET") postmark_api_key = get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY") +postmark_webhook_username = get_var_from_path_or_env(config_dir, "POSTMARK_WEBHOOK_USERNAME") +postmark_webhook_password = get_var_from_path_or_env(config_dir, "POSTMARK_WEBHOOK_PASSWORD") help_scout_app_id = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_ID") help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SECRET") help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY") @@ -621,6 +623,11 @@ config :plausible, Plausible.HelpScout, signature_key: help_scout_signature_key, vault_key: help_scout_vault_key +config :plausible, Plausible.Postmark, + api_key: postmark_api_key, + webhook_username: postmark_webhook_username, + webhook_password: postmark_webhook_password + config :plausible, :imported, max_buffer_size: get_int_from_path_or_env(config_dir, "IMPORTED_MAX_BUFFER_SIZE", 10_000) diff --git a/lib/plausible_web/controllers/api/postmark_controller.ex b/lib/plausible_web/controllers/api/postmark_controller.ex new file mode 100644 index 000000000000..7773752551b0 --- /dev/null +++ b/lib/plausible_web/controllers/api/postmark_controller.ex @@ -0,0 +1,90 @@ +defmodule PlausibleWeb.Api.PostmarkController do + @moduledoc """ + Receives Postmark's Bounce and SpamComplaint webhooks and records + addresses in `Plausible.EmailSuppressions`. + + See https://postmarkapp.com/developer/webhooks/webhooks-overview + """ + + use PlausibleWeb, :controller + + require Logger + + plug :verify_basic_auth + + # https://postmarkapp.com/developer/api/bounce-api#bounce-types + @suppressing_bounce_reasons %{ + "HardBounce" => :hard_bounce, + "BadEmailAddress" => :bad_email_address, + "Blocked" => :blocked + } + + def webhook(conn, %{"RecordType" => "Bounce"} = params) do + case Map.fetch(@suppressing_bounce_reasons, params["Type"]) do + {:ok, reason} -> + params + |> suppression_attrs() + |> Map.put(:reason, reason) + |> Plausible.EmailSuppressions.create_from_bounce() + |> log_on_error(params) + + :error -> + :ignored + end + + ok(conn) + end + + def webhook(conn, %{"RecordType" => "SpamComplaint"} = params) do + params + |> suppression_attrs() + |> Plausible.EmailSuppressions.create_from_spam_complaint() + |> log_on_error(params) + + ok(conn) + end + + def webhook(conn, params) do + Logger.warning("Ignoring Postmark webhook of type #{inspect(params["RecordType"])}") + ok(conn) + end + + defp suppression_attrs(params) do + %{ + email: params["Email"], + source: :webhook, + postmark_bounce_id: params["ID"], + postmark_inactive: params["Inactive"] || false, + can_activate: params["CanActivate"] || false, + details: params["Details"] + } + end + + defp log_on_error({:ok, _suppression}, _params), do: :ok + + defp log_on_error({:error, changeset}, params) do + Sentry.capture_message("Failed to record Postmark suppression", + extra: %{ + email: params["Email"], + record_type: params["RecordType"], + errors: inspect(changeset.errors) + } + ) + end + + defp ok(conn), do: json(conn, %{}) + + defp verify_basic_auth(conn, _opts) do + config = Application.get_env(:plausible, Plausible.Postmark, []) + + username = + Keyword.get(config, :webhook_username) || + raise "POSTMARK_WEBHOOK_USERNAME is not configured" + + password = + Keyword.get(config, :webhook_password) || + raise "POSTMARK_WEBHOOK_PASSWORD is not configured" + + Plug.BasicAuth.basic_auth(conn, username: username, password: password) + end +end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 127038318bd4..7cc1b97c9365 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -411,6 +411,8 @@ defmodule PlausibleWeb.Router do get "/error", Api.ExternalController, :error # Remove this once all external checks are migration to new /system/health/* checks get "/health", Api.SystemController, :readiness + + post "/postmark/webhook", Api.PostmarkController, :webhook end scope "/system" do diff --git a/test/plausible_web/controllers/api/postmark_controller_test.exs b/test/plausible_web/controllers/api/postmark_controller_test.exs new file mode 100644 index 000000000000..786cefc8a410 --- /dev/null +++ b/test/plausible_web/controllers/api/postmark_controller_test.exs @@ -0,0 +1,155 @@ +defmodule PlausibleWeb.Api.PostmarkControllerTest do + use PlausibleWeb.ConnCase, async: true + + import ExUnit.CaptureLog + + alias Plausible.EmailSuppressions + + # see config/.env.test + @webhook_username "fake_webhook_username" + @webhook_password "fake_webhook_password" + + setup %{conn: conn} do + conn = + Plug.Conn.put_req_header( + conn, + "authorization", + Plug.BasicAuth.encode_basic_auth(@webhook_username, @webhook_password) + ) + + {:ok, conn: conn} + end + + @bounce_payload %{ + "RecordType" => "Bounce", + "ID" => 692_560_173, + "Type" => "HardBounce", + "TypeCode" => 1, + "Email" => "bounced@example.com", + "Details" => "Unknown user", + "Inactive" => true, + "CanActivate" => true + } + + @spam_complaint_payload %{ + "RecordType" => "SpamComplaint", + "ID" => 692_560_174, + "Type" => "SpamComplaint", + "TypeCode" => 100_001, + "Email" => "complainer@example.com", + "Details" => "Test spam complaint details", + "Inactive" => true, + "CanActivate" => false + } + + describe "authentication" do + test "rejects requests without valid basic auth", %{conn: conn} do + conn = + conn + |> Plug.Conn.put_req_header( + "authorization", + Plug.BasicAuth.encode_basic_auth("wrong", "creds") + ) + |> post(Routes.postmark_path(conn, :webhook), @bounce_payload) + + assert conn.status == 401 + refute EmailSuppressions.suppressed?("bounced@example.com") + end + + test "rejects requests with no authorization header at all", %{conn: conn} do + conn = + conn + |> Plug.Conn.delete_req_header("authorization") + |> post(Routes.postmark_path(conn, :webhook), @bounce_payload) + + assert conn.status == 401 + end + end + + describe "Bounce webhook" do + test "suppresses on a hard bounce", %{conn: conn} do + conn = post(conn, Routes.postmark_path(conn, :webhook), @bounce_payload) + + assert json_response(conn, 200) == %{} + assert EmailSuppressions.suppressed?("bounced@example.com") + end + + test "suppresses on a bad email address", %{conn: conn} do + payload = %{@bounce_payload | "Type" => "BadEmailAddress"} + conn = post(conn, Routes.postmark_path(conn, :webhook), payload) + + assert json_response(conn, 200) == %{} + assert EmailSuppressions.suppressed?("bounced@example.com") + end + + test "suppresses on an ISP block", %{conn: conn} do + payload = %{@bounce_payload | "Type" => "Blocked"} + conn = post(conn, Routes.postmark_path(conn, :webhook), payload) + + assert json_response(conn, 200) == %{} + assert EmailSuppressions.suppressed?("bounced@example.com") + end + + test "ignores a transient/soft bounce", %{conn: conn} do + payload = %{@bounce_payload | "Type" => "Transient"} + conn = post(conn, Routes.postmark_path(conn, :webhook), payload) + + assert json_response(conn, 200) == %{} + refute EmailSuppressions.suppressed?("bounced@example.com") + end + + test "records the Postmark bounce details", %{conn: conn} do + post(conn, Routes.postmark_path(conn, :webhook), @bounce_payload) + + suppression = + Plausible.Repo.get_by!(Plausible.EmailSuppression, email: "bounced@example.com") + + assert suppression.reason == :hard_bounce + assert suppression.source == :webhook + assert suppression.postmark_bounce_id == 692_560_173 + assert suppression.postmark_inactive == true + assert suppression.can_activate == true + assert suppression.details == "Unknown user" + end + + test "still acknowledges the webhook when the payload can't be persisted", %{conn: conn} do + payload = Map.delete(@bounce_payload, "Email") + conn = post(conn, Routes.postmark_path(conn, :webhook), payload) + + assert json_response(conn, 200) == %{} + assert Plausible.Repo.aggregate(Plausible.EmailSuppression, :count) == 0 + end + end + + describe "SpamComplaint webhook" do + test "suppresses the complaining address", %{conn: conn} do + conn = post(conn, Routes.postmark_path(conn, :webhook), @spam_complaint_payload) + + assert json_response(conn, 200) == %{} + assert EmailSuppressions.suppressed?("complainer@example.com") + + suppression = + Plausible.Repo.get_by!(Plausible.EmailSuppression, email: "complainer@example.com") + + assert suppression.reason == :spam_complaint + end + end + + describe "other webhook types" do + test "acknowledges but ignores unhandled record types, logging a notice", %{conn: conn} do + {conn, log} = + with_log(fn -> + post(conn, Routes.postmark_path(conn, :webhook), %{ + "RecordType" => "Delivery", + "Email" => "delivered@example.com" + }) + end) + + assert json_response(conn, 200) == %{} + refute EmailSuppressions.suppressed?("delivered@example.com") + + assert log =~ "[warning]" + assert log =~ ~s(Ignoring Postmark webhook of type "Delivery") + end + end +end From 98143e2a5d516255bb2389cbad1c5aba69281ae3 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 17 Sep 2026 07:34:17 +0200 Subject: [PATCH 2/2] Use Sentry to capture misconfigured webhook types --- .../controllers/api/postmark_controller.ex | 7 ++--- .../api/postmark_controller_test.exs | 26 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/plausible_web/controllers/api/postmark_controller.ex b/lib/plausible_web/controllers/api/postmark_controller.ex index 7773752551b0..da1f72e54890 100644 --- a/lib/plausible_web/controllers/api/postmark_controller.ex +++ b/lib/plausible_web/controllers/api/postmark_controller.ex @@ -8,8 +8,6 @@ defmodule PlausibleWeb.Api.PostmarkController do use PlausibleWeb, :controller - require Logger - plug :verify_basic_auth # https://postmarkapp.com/developer/api/bounce-api#bounce-types @@ -45,7 +43,10 @@ defmodule PlausibleWeb.Api.PostmarkController do end def webhook(conn, params) do - Logger.warning("Ignoring Postmark webhook of type #{inspect(params["RecordType"])}") + Sentry.capture_message("Received unexpected Postmark webhook record type", + extra: %{record_type: params["RecordType"], params: params} + ) + ok(conn) end diff --git a/test/plausible_web/controllers/api/postmark_controller_test.exs b/test/plausible_web/controllers/api/postmark_controller_test.exs index 786cefc8a410..230b1896b2d6 100644 --- a/test/plausible_web/controllers/api/postmark_controller_test.exs +++ b/test/plausible_web/controllers/api/postmark_controller_test.exs @@ -1,8 +1,6 @@ defmodule PlausibleWeb.Api.PostmarkControllerTest do use PlausibleWeb.ConnCase, async: true - import ExUnit.CaptureLog - alias Plausible.EmailSuppressions # see config/.env.test @@ -136,20 +134,24 @@ defmodule PlausibleWeb.Api.PostmarkControllerTest do end describe "other webhook types" do - test "acknowledges but ignores unhandled record types, logging a notice", %{conn: conn} do - {conn, log} = - with_log(fn -> - post(conn, Routes.postmark_path(conn, :webhook), %{ - "RecordType" => "Delivery", - "Email" => "delivered@example.com" - }) - end) + setup %{test_pid: test_pid} do + Plausible.Test.Support.Sentry.setup(test_pid) + end + + test "acknowledges but ignores unhandled record types, reporting to Sentry", %{conn: conn} do + conn = + post(conn, Routes.postmark_path(conn, :webhook), %{ + "RecordType" => "Delivery", + "Email" => "delivered@example.com" + }) assert json_response(conn, 200) == %{} refute EmailSuppressions.suppressed?("delivered@example.com") - assert log =~ "[warning]" - assert log =~ ~s(Ignoring Postmark webhook of type "Delivery") + assert [report] = Sentry.Test.pop_sentry_reports() + assert report.message.formatted == "Received unexpected Postmark webhook record type" + assert report.extra.record_type == "Delivery" + assert report.extra.params["Email"] == "delivered@example.com" end end end