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
2 changes: 2 additions & 0 deletions config/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down
91 changes: 91 additions & 0 deletions lib/plausible_web/controllers/api/postmark_controller.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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

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
Sentry.capture_message("Received unexpected Postmark webhook record type",
extra: %{record_type: params["RecordType"], params: params}
)

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
2 changes: 2 additions & 0 deletions lib/plausible_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
157 changes: 157 additions & 0 deletions test/plausible_web/controllers/api/postmark_controller_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
defmodule PlausibleWeb.Api.PostmarkControllerTest do
use PlausibleWeb.ConnCase, async: true

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
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 [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
Loading