Skip to content
Merged
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 Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ gem "sqlite3"
gem "minitest", "~> 5.16"

gem "standard", "~> 1.3"

gem "lefthook", "~> 1.13"
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ GEM
reline (>= 0.4.2)
json (2.15.0)
language_server-protocol (3.17.0.5)
lefthook (1.13.6)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.24.1)
Expand Down Expand Up @@ -260,6 +261,7 @@ PLATFORMS

DEPENDENCIES
irb
lefthook (~> 1.13)
minitest (~> 5.16)
missive!
puma
Expand Down
45 changes: 40 additions & 5 deletions app/controllers/missive/postmark/webhooks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@ module Missive
class Postmark::WebhooksController < ApplicationController
skip_forgery_protection

class RecipientNotMatching < StandardError; end

before_action :verify_webhook
before_action :set_payload, only: :receive
before_action :set_subscriber, only: :receive

rescue_from RecipientNotMatching, with: :handle_bad_request
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from NoMatchingPatternError, with: :handle_no_matching_pattern

def receive
case @payload
in {RecordType: "SubscriptionChange", ChangedAt: changed_at, SuppressSending: true, SuppressionReason: suppression_reason}
@subscriber.update!(suppressed_at: changed_at, suppression_reason: suppression_reason.underscore)
in {RecordType: "SubscriptionChange", ChangedAt: changed_at, SuppressSending: false}
in {RecordType: "Delivery", DeliveredAt: delivered_at}
set_dispatch
set_subscriber
check_dispatch_recipient!
@dispatch.update!(delivered_at:)
in {RecordType: "SubscriptionChange", ChangedAt: suppressed_at, SuppressSending: true, SuppressionReason: suppression_reason}
set_subscriber
@subscriber.update!(suppressed_at:, suppression_reason: suppression_reason.underscore)
in {RecordType: "SubscriptionChange", SuppressSending: false}
set_subscriber
@subscriber.update!(suppressed_at: nil, suppression_reason: nil)
end

Expand All @@ -22,19 +34,42 @@ def receive
def verify_webhook
secret_header = request.headers["HTTP_X_POSTMARK_SECRET"]

head :unauthorized if secret_header != webhooks_secret
render plain: "Cannot verify webhook", status: :unauthorized if secret_header != webhooks_secret
end

def set_payload
@payload = JSON.parse(request.body.read).with_indifferent_access
end

def set_dispatch
@dispatch = Dispatch.find_by!(postmark_message_id: @payload[:MessageID])
end

def set_subscriber
@subscriber = Subscriber.find_by!(email: @payload[:Recipient])
end

def check_dispatch_recipient!
return unless @dispatch.subscriber != @subscriber

raise RecipientNotMatching,
"Dispatch subscriber #{@dispatch.subscriber.email} does not match payload recipient #{@payload[:Recipient]}"
end

def webhooks_secret
Rails.application.credentials.postmark.webhooks_secret
end

def handle_bad_request(e)
render plain: e.message, status: :bad_request
end

def handle_not_found(e)
render plain: "#{e.model} not found", status: :not_found
end

def handle_no_matching_pattern
render plain: "Webhook payload not supported", status: :unprocessable_entity
end
end
end
13 changes: 13 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# EXAMPLE USAGE:
#
# Refer for explanation to following link:
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md

pre-commit:
parallel: true
commands:
standard:
tags: ruby style
glob: "**/*.rb"
run: bundle exec rake standard:fix {staged_files}
stage_fixed: true
80 changes: 80 additions & 0 deletions test/integration/postmark/webhooks_delivery_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
require "test_helper"

module Missive
class Postmark::WebhooksDeliveryTest < ActionDispatch::IntegrationTest
include Engine.routes.url_helpers

setup do
@routes = Engine.routes
@headers = {"HTTP_X_POSTMARK_SECRET" => Rails.application.credentials.postmark.webhooks_secret}
@dispatch = missive_dispatches(:john_first_newsletter)
end

test "receive delivery payload" do
@payload = {
"RecordType" => "Delivery",
"MessageID" => @dispatch.postmark_message_id,
"Recipient" => @dispatch.subscriber.email,
"DeliveredAt" => "2025-09-14T16:30:00.0000000Z",
"Details" => "Test delivery webhook details",
"Tag" => "welcome-email",
"ServerID" => 23,
"Metadata" => {
"example" => "value",
"example_2" => "value"
},
"MessageStream" => "bulk"
}

action
assert_equal 200, status
@dispatch.reload
assert @dispatch.delivered?
assert_equal Time.utc(2025, 9, 14, 16, 30), @dispatch.delivered_at
end

test "receive nonexistent recipient" do
@payload = {
"RecordType" => "Delivery",
"MessageID" => @dispatch.postmark_message_id,
"Recipient" => "fake@example.com",
"DeliveredAt" => "2025-09-14T16:30:00.0000000Z"
}

action
assert_equal 404, status
assert_match "Missive::Subscriber not found", response.body
end

test "receive nonexistent dispatch" do
@payload = {
"RecordType" => "Delivery",
"MessageID" => "WRONG",
"DeliveredAt" => "2025-09-14T16:30:00.0000000Z"
}

action
assert_equal 404, status
assert_match "Missive::Dispatch not found", response.body
end

test "receive recipient not matching dispatch" do
@payload = {
"RecordType" => "Delivery",
"MessageID" => @dispatch.postmark_message_id,
"Recipient" => "jane@example.com",
"DeliveredAt" => "2025-09-14T16:30:00.0000000Z"
}

action
assert_equal 400, status
assert_match "Dispatch subscriber #{@dispatch.subscriber.email} does not match payload recipient jane@example.com", response.body
end

private

def action
post postmark_webhooks_path, headers: @headers, env: {RAW_POST_DATA: @payload.to_json}
end
end
end
133 changes: 133 additions & 0 deletions test/integration/postmark/webhooks_subscription_change_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
require "test_helper"

module Missive
class Postmark::WebhooksSubscriptionChangeTest < ActionDispatch::IntegrationTest
include Engine.routes.url_helpers

setup do
@routes = Engine.routes
@headers = {"HTTP_X_POSTMARK_SECRET" => Rails.application.credentials.postmark.webhooks_secret}
@subscriber = missive_subscribers(:john)
end

test "receive bounce payload" do
@payload = {
"RecordType" => "SubscriptionChange",
"MessageID" => "00000000-0000-0000-0000-000000000000",
"ServerID" => 23,
"MessageStream" => "bulk",
"ChangedAt" => "2025-03-29T20:49:48Z",
"Recipient" => @subscriber.email,
"Origin" => "Recipient",
"SuppressSending" => true,
"SuppressionReason" => "HardBounce",
"Tag" => "welcome-email",
"Metadata" => {
"example" => "value",
"example_2" => "value"
}
}

action
assert_equal 200, status
@subscriber.reload
assert @subscriber.suppressed?
assert @subscriber.hard_bounce?
end

test "receive spam complaint payload" do
@payload = {
"RecordType" => "SubscriptionChange",
"MessageID" => "00000000-0000-0000-0000-000000000000",
"ServerID" => 23,
"MessageStream" => "bulk",
"ChangedAt" => "2025-03-29T20:49:48Z",
"Recipient" => @subscriber.email,
"Origin" => "Recipient",
"SuppressSending" => true,
"SuppressionReason" => "SpamComplaint",
"Tag" => "welcome-email",
"Metadata" => {
"example" => "value",
"example_2" => "value"
}
}

action
assert_equal 200, status
@subscriber.reload
assert @subscriber.suppressed?
assert @subscriber.spam_complaint?
end

test "receive manual suppression payload" do
@payload = {
"RecordType" => "SubscriptionChange",
"MessageID" => "00000000-0000-0000-0000-000000000000",
"ServerID" => 23,
"MessageStream" => "bulk",
"ChangedAt" => "2025-03-29T20:49:48Z",
"Recipient" => @subscriber.email,
"Origin" => "Recipient",
"SuppressSending" => true,
"SuppressionReason" => "ManualSuppression",
"Tag" => "welcome-email",
"Metadata" => {
"example" => "value",
"example_2" => "value"
}
}

action
assert_equal 200, status
@subscriber.reload
assert @subscriber.suppressed?
assert @subscriber.manual_suppression?
end

test "receive resubscription payload" do
@subscriber = missive_subscribers(:jane)
@payload = {
"RecordType" => "SubscriptionChange",
"MessageID" => "00000000-0000-0000-0000-000000000000",
"ServerID" => 23,
"MessageStream" => "bulk",
"ChangedAt" => "2025-03-29T20:49:48Z",
"Recipient" => @subscriber.email,
"Origin" => "Recipient",
"SuppressSending" => false,
"SuppressionReason" => "ManualSuppression",
"Tag" => "welcome-email",
"Metadata" => {
"example" => "value",
"example_2" => "value"
}
}

action
assert_equal 200, status
@subscriber.reload
assert_not @subscriber.suppressed?
assert_nil @subscriber.suppression_reason
end

test "receive nonexistent recipient" do
@payload = {
"RecordType" => "SubscriptionChange",
"Recipient" => "fake@example.com",
"SuppressSending" => false,
"SuppressionReason" => "ManualSuppression"
}

action
assert_equal 404, status
assert_match "Missive::Subscriber not found", response.body
end

private

def action
post postmark_webhooks_path, headers: @headers, env: {RAW_POST_DATA: @payload.to_json}
end
end
end
Loading