From 7d46d93ea138642baeda65dbad4513d1c8f61dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Furkan=20=C3=96zt=C3=BCrk?= Date: Fri, 20 Jun 2025 11:39:30 +0300 Subject: [PATCH 1/5] fix: Test Suite won't build on GitHub Actions --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f55343fe6..50109b76d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,10 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - run: docker-compose pull + - run: docker compose pull env: POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }} - - run: docker-compose run postal sh -c 'bundle exec rspec' + - run: docker compose run postal sh -c 'bundle exec rspec' env: POSTAL_IMAGE: ghcr.io/postalserver/postal:ci-${{ github.sha }} From b7fa74532f345f48daf8f123ccf4ed55391e1124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Furkan=20=C3=96zt=C3=BCrk?= Date: Fri, 20 Jun 2025 11:40:24 +0300 Subject: [PATCH 2/5] fix: db can't migrate --- db/migrate/20161003195209_create_authie_sessions.authie.rb | 2 +- .../20161003195210_add_indexes_to_authie_sessions.authie.rb | 2 +- .../20161003195211_add_parent_id_to_authie_sessions.authie.rb | 2 +- ...0161003195212_add_two_factor_auth_fields_to_authie.authie.rb | 2 +- db/migrate/20170418200606_initial_schema.rb | 2 +- ...20170421195414_add_token_hashes_to_authie_sessions.authie.rb | 2 +- ...95415_add_index_to_token_hashes_on_authie_sessions.authie.rb | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/db/migrate/20161003195209_create_authie_sessions.authie.rb b/db/migrate/20161003195209_create_authie_sessions.authie.rb index 1b6e990a9..99d19d112 100644 --- a/db/migrate/20161003195209_create_authie_sessions.authie.rb +++ b/db/migrate/20161003195209_create_authie_sessions.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20141012174250) -class CreateAuthieSessions < ActiveRecord::Migration +class CreateAuthieSessions < ActiveRecord::Migration[7.0] def change end diff --git a/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb b/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb index 8cb984e9a..7c10e6557 100644 --- a/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb +++ b/db/migrate/20161003195210_add_indexes_to_authie_sessions.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20141013115205) -class AddIndexesToAuthieSessions < ActiveRecord::Migration +class AddIndexesToAuthieSessions < ActiveRecord::Migration[7.0] def change end diff --git a/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb b/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb index 0bd5bd1bc..030e7cd49 100644 --- a/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb +++ b/db/migrate/20161003195211_add_parent_id_to_authie_sessions.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20150109144120) -class AddParentIdToAuthieSessions < ActiveRecord::Migration +class AddParentIdToAuthieSessions < ActiveRecord::Migration[7.0] def change end diff --git a/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb b/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb index 9ae1f4407..0adf236cb 100644 --- a/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb +++ b/db/migrate/20161003195212_add_two_factor_auth_fields_to_authie.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20150305135400) -class AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration +class AddTwoFactorAuthFieldsToAuthie < ActiveRecord::Migration[7.0] def change end diff --git a/db/migrate/20170418200606_initial_schema.rb b/db/migrate/20170418200606_initial_schema.rb index 76c3eb1cd..8e90ca36e 100644 --- a/db/migrate/20170418200606_initial_schema.rb +++ b/db/migrate/20170418200606_initial_schema.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class InitialSchema < ActiveRecord::Migration +class InitialSchema < ActiveRecord::Migration[7.0] def up create_table "additional_route_endpoints", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" do |t| diff --git a/db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb b/db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb index 90baa7d86..2a5a3f1cc 100644 --- a/db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb +++ b/db/migrate/20170421195414_add_token_hashes_to_authie_sessions.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20170417170000) -class AddTokenHashesToAuthieSessions < ActiveRecord::Migration +class AddTokenHashesToAuthieSessions < ActiveRecord::Migration[7.0] def change end diff --git a/db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb b/db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb index 7cb9b330b..16b902fb8 100644 --- a/db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb +++ b/db/migrate/20170421195415_add_index_to_token_hashes_on_authie_sessions.authie.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # This migration comes from authie (originally 20170421174100) -class AddIndexToTokenHashesOnAuthieSessions < ActiveRecord::Migration +class AddIndexToTokenHashesOnAuthieSessions < ActiveRecord::Migration[7.0] def change add_index :authie_sessions, :token_hash, length: 8 From b3d61551779aec4c50d6620d61d45b54a5cf3d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Furkan=20=C3=96zt=C3=BCrk?= Date: Fri, 20 Jun 2025 11:45:10 +0300 Subject: [PATCH 3/5] feature: add bounce_type to webhook output --- .../legacy_api/messages_controller.rb | 1 + .../incoming_message_processor.rb | 3 +- lib/postal/message_db/message.rb | 3 +- .../21_add_bounce_type_to_messages.rb | 15 ++++++++ script/insert-bounce.rb | 37 ++++++++++++++++--- spec/apis/legacy_api/messages/message_spec.rb | 2 + .../incoming_message_processor_spec.rb | 18 +++++++++ 7 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 lib/postal/message_db/migrations/21_add_bounce_type_to_messages.rb diff --git a/app/controllers/legacy_api/messages_controller.rb b/app/controllers/legacy_api/messages_controller.rb index 6d78c1618..42593799d 100644 --- a/app/controllers/legacy_api/messages_controller.rb +++ b/app/controllers/legacy_api/messages_controller.rb @@ -44,6 +44,7 @@ def message size: message.size, bounce: message.bounce, bounce_for_id: message.bounce_for_id, + bounce_type: message.bounce_type, tag: message.tag, received_with_ssl: message.received_with_ssl } diff --git a/app/lib/message_dequeuer/incoming_message_processor.rb b/app/lib/message_dequeuer/incoming_message_processor.rb index 6f69dbceb..5f2b81597 100644 --- a/app/lib/message_dequeuer/incoming_message_processor.rb +++ b/app/lib/message_dequeuer/incoming_message_processor.rb @@ -37,7 +37,7 @@ def handle_bounces original_messages = queued_message.message.original_messages unless original_messages.empty? queued_message.message.original_messages.each do |orig_msg| - queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id) + queued_message.message.update(bounce_for_id: orig_msg.id, domain_id: orig_msg.domain_id, bounce_type: "soft") create_delivery "Processed", details: "This has been detected as a bounce message for ." orig_msg.bounce!(queued_message.message) log "bounce linked with message #{orig_msg.id}" @@ -51,6 +51,7 @@ def handle_bounces # otherwise we'll drop at this point. return unless queued_message.message.route_id.nil? + queued_message.message.update(bounce_type: "hard") log "no source messages found, hard failing" create_delivery "HardFail", details: "This message was a bounce but we couldn't link it with any outgoing message and there was no route for it." remove_from_queue diff --git a/lib/postal/message_db/message.rb b/lib/postal/message_db/message.rb index 3a2c7a690..0a235a3e2 100644 --- a/lib/postal/message_db/message.rb +++ b/lib/postal/message_db/message.rb @@ -446,7 +446,8 @@ def webhook_hash subject: subject, timestamp: timestamp.to_f, spam_status: spam_status, - tag: tag + tag: tag, + bounce_type: bounce_type } end diff --git a/lib/postal/message_db/migrations/21_add_bounce_type_to_messages.rb b/lib/postal/message_db/migrations/21_add_bounce_type_to_messages.rb new file mode 100644 index 000000000..2d4535cbe --- /dev/null +++ b/lib/postal/message_db/migrations/21_add_bounce_type_to_messages.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Postal + module MessageDB + module Migrations + class AddBounceTypeToMessages < Postal::MessageDB::Migration + + def up + @database.query("ALTER TABLE `#{@database.database_name}`.`messages` ADD COLUMN `bounce_type` varchar(4) DEFAULT NULL") + end + + end + end + end +end diff --git a/script/insert-bounce.rb b/script/insert-bounce.rb index 3c43284de..01942f789 100755 --- a/script/insert-bounce.rb +++ b/script/insert-bounce.rb @@ -4,13 +4,26 @@ # This script will insert a message into your database that looks like a bounce # for a message that you specify. -# usage: insert-bounce.rb [serverid] [messageid] +# usage: insert-bounce.rb [serverid] [messageid] [bounce_type] +# bounce_type is optional and can be 'soft' or 'hard' if ARGV[0].nil? || ARGV[1].nil? - puts "usage: #{__FILE__} [server-id] [message-id]" + puts "usage: #{__FILE__} [server-id] [message-id] [bounce_type]" + puts "bounce_type is optional and can be 'soft' or 'hard'" exit 1 end +# Validate bounce_type if provided +bounce_type = nil +if ARGV[2] + bounce_type = ARGV[2].downcase + unless %w[soft hard].include?(bounce_type) + puts "Error: bounce_type must be either 'soft' or 'hard'" + puts "usage: #{__FILE__} [server-id] [message-id] [bounce_type]" + exit 1 + end +end + require_relative "../config/environment" server = Server.find(ARGV[0]) @@ -19,9 +32,9 @@ template = File.read(Rails.root.join("resource/postfix-bounce.msg")) if ARGV[1].to_s =~ /\A(\d+)\z/ - message = server.message_db.message(ARGV[1].to_i) - puts "Got message #{message.id} with token #{message.token}" - template.gsub!("{{MSGID}}", message.token) + original_message = server.message_db.message(ARGV[1].to_i) + puts "Got message #{original_message.id} with token #{original_message.token}" + template.gsub!("{{MSGID}}", original_message.token) else template.gsub!("{{MSGID}}", ARGV[1].to_s) end @@ -32,5 +45,19 @@ message.mail_from = "MAILER-DAEMON@smtp.infra.atech.io" message.raw_message = template message.bounce = true + +# Set bounce_type if provided +if bounce_type + message.bounce_type = bounce_type + puts "Setting bounce_type to: #{bounce_type}" +end + message.save puts "Added message with id #{message.id}" + +# If we found the original message and bounce_type is set, link the bounce and trigger webhook +if defined?(original_message) && bounce_type + message.update(bounce_for_id: original_message.id, domain_id: original_message.domain_id) + original_message.bounce!(message) + puts "Linked bounce to original message #{original_message.id} and triggered webhook" +end diff --git a/spec/apis/legacy_api/messages/message_spec.rb b/spec/apis/legacy_api/messages/message_spec.rb index 202b66b50..d2b5924fd 100644 --- a/spec/apis/legacy_api/messages/message_spec.rb +++ b/spec/apis/legacy_api/messages/message_spec.rb @@ -104,6 +104,7 @@ "status" => "Pending" }, "details" => { "bounce" => false, "bounce_for_id" => 0, + "bounce_type" => nil, "direction" => "outgoing", "mail_from" => "test@example.com", "message_id" => message.message_id, @@ -161,6 +162,7 @@ "token" => message.token, "details" => { "bounce" => false, "bounce_for_id" => 0, + "bounce_type" => nil, "direction" => "outgoing", "mail_from" => "test@example.com", "message_id" => message.message_id, diff --git a/spec/lib/message_dequeuer/incoming_message_processor_spec.rb b/spec/lib/message_dequeuer/incoming_message_processor_spec.rb index 8b5bd37bf..62df14114 100644 --- a/spec/lib/message_dequeuer/incoming_message_processor_spec.rb +++ b/spec/lib/message_dequeuer/incoming_message_processor_spec.rb @@ -37,6 +37,11 @@ module MessageDequeuer expect(delivery).to have_attributes(status: "HardFail", details: /was a bounce but we couldn't link it with any outgoing message/i) end + it "sets the bounce_type to hard" do + processor.process + expect(message.reload.bounce_type).to eq "hard" + end + it "removes the queued message" do processor.process expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound) @@ -93,6 +98,19 @@ module MessageDequeuer processor.process end + it "triggers a MessageBounced webhook event with bounce_type" do + expect(WebhookRequest).to receive(:trigger).with(server, "MessageBounced", { + original_message: hash_including(bounce_type: nil), + bounce: hash_including(bounce_type: "soft") + }) + processor.process + end + + it "sets the bounce_type to soft" do + processor.process + expect(message.reload.bounce_type).to eq "soft" + end + it "removes the queued message" do processor.process expect { queued_message.reload }.to raise_error(ActiveRecord::RecordNotFound) From 39b0d85a72380bcfa364966c6d3a66aae978f2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Furkan=20=C3=96zt=C3=BCrk?= Date: Fri, 20 Jun 2025 11:48:25 +0300 Subject: [PATCH 4/5] feature: able to choose webhook output styles and add listmonk style output for webhooks --- .../application/application.coffee | 55 ++++++++++++ .../application/components/_webhook_list.scss | 86 +++++++++++-------- app/controllers/webhooks_controller.rb | 2 +- app/models/webhook.rb | 2 + app/services/webhook_delivery_service.rb | 58 ++++++++++--- app/views/webhooks/_form.html.haml | 20 +++-- app/views/webhooks/index.html.haml | 4 + ...0616160443_add_output_style_to_webhooks.rb | 9 ++ db/schema.rb | 3 +- doc/config/yaml.yml | 8 +- lib/postal/config_schema.rb | 12 +++ spec/factories/webhook_factory.rb | 2 + 12 files changed, 209 insertions(+), 52 deletions(-) create mode 100644 db/migrate/20250616160443_add_output_style_to_webhooks.rb diff --git a/app/assets/javascripts/application/application.coffee b/app/assets/javascripts/application/application.coffee index 2ad8eaf98..49ef7b511 100644 --- a/app/assets/javascripts/application/application.coffee +++ b/app/assets/javascripts/application/application.coffee @@ -70,3 +70,58 @@ $ -> credentialTypeInput = $('select#credential_type') if credentialTypeInput.length toggleCredentialInputs(credentialTypeInput.val()) + + handleWebhookOutputStyle = -> + $outputStyleSelect = $('.js-output-style-select') + $allEventsSelect = $('.js-all-events-select') + $allEventsField = $allEventsSelect.closest('.fieldSet__field') + $checkboxes = $('.js-event-checkbox') + $nonBounceEvents = $('.js-non-bounce-event') + $listmonkNotice = $('.js-listmonk-notice') + + return unless $outputStyleSelect.length + + updateForOutputStyle = -> + isListmonk = $outputStyleSelect.val() == 'listmonk' + + if isListmonk + $listmonkNotice.show() + $allEventsSelect.hide() + $allEventsSelect.val('false').trigger('change') + + # Disable all events except MessageBounced and ensure MessageBounced is checked + $checkboxes.each -> + $checkbox = $(this) + eventType = $checkbox.data('event') + if eventType != 'MessageBounced' + $checkbox.prop('disabled', true).prop('checked', false) + else + $checkbox.prop('checked', true) + + # Gray out non-bounce event items + $nonBounceEvents.addClass('is-disabled').css + 'opacity': '0.5' + 'pointer-events': 'none' + else + $listmonkNotice.hide() + $allEventsSelect.show() + $checkboxes.prop('disabled', false) + + $nonBounceEvents.removeClass('is-disabled').css + 'opacity': '1' + 'pointer-events': 'auto' + + updateForOutputStyle() + $outputStyleSelect.on('change', updateForOutputStyle) + + # Prevent form submission if listmonk is selected but no MessageBounced event + $('form').on 'submit', (e) -> + if $outputStyleSelect.val() == 'listmonk' + messageBounceChecked = $('input[data-event="MessageBounced"]').is(':checked') + unless messageBounceChecked + alert('Listmonk output style requires the MessageBounced event to be selected.') + e.preventDefault() + return false + + $(document).on 'turbolinks:load', -> + handleWebhookOutputStyle() diff --git a/app/assets/stylesheets/application/components/_webhook_list.scss b/app/assets/stylesheets/application/components/_webhook_list.scss index 4abddecc3..80a3cdb51 100644 --- a/app/assets/stylesheets/application/components/_webhook_list.scss +++ b/app/assets/stylesheets/application/components/_webhook_list.scss @@ -1,68 +1,86 @@ .webhookList { - border-radius:4px; - overflow:hidden; - box-shadow:0 0 10px rgba(0,0,0,0.2); + border-radius: 4px; + overflow: hidden; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); } .webhookList__item { - background:#fff; - padding:15px; + background: #fff; + padding: 15px; } .webhookList__item:nth-child(even) { - background:none; + background: none; } .webhookList__item + .webhookList__item { - border-top:1px solid lighten(#ccd4e0, 10%); + border-top: 1px solid lighten(#ccd4e0, 10%); } .webhookList__top { - display:flex; - align-items: center; - min-width:1px; + display: flex; + align-items: center; + min-width: 1px; } .webhookList__labels { - flex: 0 0 auto; - line-height:0; - margin-left:10px; - .label + .label { - margin-left:2px; - } + flex: 0 0 auto; + line-height: 0; + margin-left: 10px; + .label + .label { + margin-left: 2px; + } } .webhookList__name { - font-weight:600; - flex: 1 1 auto; - overflow:hidden; - text-overflow:ellipsis; - line-height:1.4; + font-weight: 600; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.4; } .webhookList__bottom { - display:flex; - margin-top:3px; - font-size:12px; + display: flex; + margin-top: 3px; + font-size: 12px; } .webhookList__usageTime { - color:#999; - line-height:1.4; - flex: 1 1 auto; + color: #999; + line-height: 1.4; + flex: 1 1 auto; } .webhookList__links { - flex: 0 0 auto; - display:flex; + flex: 0 0 auto; + display: flex; } .webhookList__link { - a { - color:#999; - text-decoration: underline; - } + a { + color: #999; + text-decoration: underline; + } } .webhookList__link + .webhookList__link { - margin-left:12px; + margin-left: 12px; +} + +.checkboxList__item.is-disabled { + background-color: #f8f9fa; + color: #6c757d; +} + +.checkboxList__item.is-disabled .checkboxList__actualLabel { + color: #6c757d; +} + +.checkboxList__item.is-disabled .checkBoxList__text { + color: #adb5bd; +} + +.checkboxList__item.is-disabled input[type="checkbox"]:disabled { + opacity: 0.5; + cursor: not-allowed; } diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 0d76f5b54..b074e65da 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -48,7 +48,7 @@ def history_request private def safe_params - params.require(:webhook).permit(:name, :url, :all_events, :enabled, events: []) + params.require(:webhook).permit(:name, :url, :all_events, :enabled, :output_style, events: []) end end diff --git a/app/models/webhook.rb b/app/models/webhook.rb index cfa9891bc..b399c69bb 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -12,6 +12,7 @@ # last_used_at :datetime # all_events :boolean default(FALSE) # enabled :boolean default(TRUE) +# output_style :string(255) default("postal") # sign :boolean default(TRUE) # created_at :datetime # updated_at :datetime @@ -31,6 +32,7 @@ class Webhook < ApplicationRecord validates :name, presence: true validates :url, presence: true, format: { with: /\Ahttps?:\/\/[a-z0-9\-._?=&\/+:%@]+\z/i, allow_blank: true } + validates :output_style, inclusion: { in: %w[postal listmonk], message: "%{value} is not a valid output style" } scope :enabled, -> { where(enabled: true) } diff --git a/app/services/webhook_delivery_service.rb b/app/services/webhook_delivery_service.rb index 4aeb50c22..f644a2e7f 100644 --- a/app/services/webhook_delivery_service.rb +++ b/app/services/webhook_delivery_service.rb @@ -6,6 +6,7 @@ class WebhookDeliveryService def initialize(webhook_request:) @webhook_request = webhook_request + @webhook = @webhook_request.webhook end def call @@ -25,19 +26,36 @@ def success? private def generate_payload - @payload = { - event: @webhook_request.event, - timestamp: @webhook_request.created_at.to_f, - payload: @webhook_request.payload, - uuid: @webhook_request.uuid - }.to_json + case @webhook.output_style + when "listmonk" + payload_data = generate_listmonk_payload + if payload_data.nil? + raise Postal::Error, "Unsupported event '#{@webhook_request.event}' for output style 'listmonk'" + end + @payload = payload_data.to_json + else + @payload = { + event: @webhook_request.event, + timestamp: @webhook_request.created_at.to_f, + payload: @webhook_request.payload, + uuid: @webhook_request.uuid + }.to_json + end end def send_request - @http_result = Postal::HTTP.post(@webhook_request.url, - sign: true, - json: @payload, - timeout: 5) + options = { + sign: true, + json: @payload, + timeout: 5 + } + + if @webhook.output_style == 'listmonk' + options[:username] = Postal::Config.listmonk.api_user + options[:password] = Postal::Config.listmonk.api_key + end + + @http_result = Postal::HTTP.post(@webhook_request.url, options) @success = (@http_result[:code] >= 200 && @http_result[:code] < 300) end @@ -90,6 +108,26 @@ def update_webhook_request @webhook_request.destroy! end + def generate_listmonk_payload + case @webhook_request.event + when "MessageBounced" + payload_data = @webhook_request.payload + bounce_data = payload_data[:bounce] || payload_data["bounce"] + original_data = payload_data[:original_message] || payload_data["original_message"] + + bounce_type = bounce_data[:bounce_type] || bounce_data["bounce_type"] + email = original_data[:to] || original_data["to"] + + { + email: email, + source: "postal", + type: bounce_type == "soft" ? "soft" : "hard" + } + else + nil + end + end + def logger Postal.logger end diff --git a/app/views/webhooks/_form.html.haml b/app/views/webhooks/_form.html.haml index fdd0f1027..b907aa186 100644 --- a/app/views/webhooks/_form.html.haml +++ b/app/views/webhooks/_form.html.haml @@ -23,17 +23,28 @@ You can enable or disable this webhook without fully removing it from the system. If there are any outstanding webhook deliveries, they will still be completed even if disabled. + .fieldSet__field + = f.label :output_style, 'Output Style', :class => 'fieldSet__label' + .fieldSet__input + = f.select :output_style, [["Postal - Standard Postal webhook format", "postal"], ["Listmonk - Listmonk compatible format", "listmonk"]], {}, :class => 'input input--select js-output-style-select' + %p.fieldSet__text + Choose the format for webhook payloads. Postal format provides full event details, while Listmonk format + is compatible with Listmonk's webhook expectations for bounce handling. + %p.fieldSet__text.js-listmonk-notice{:style => "display: none; color: #856404; background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 8px; border-radius: 4px; margin-top: 8px;"} + %strong Note: + When Listmonk format is selected, only the MessageBounced event is supported. Other events will be automatically disabled. + .fieldSet__field = f.label :all_events, 'Events', :class => 'fieldSet__label' .fieldSet__input = hidden_field_tag 'webhook[events][]' - = f.select :all_events, [["Yes - send all events to this URL", true], ["No - I'll choose which requests to send", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle' + = f.select :all_events, [["Yes - send all events to this URL", true], ["No - I'll choose which requests to send", false]], {},:class => 'input input--select fieldSet__checkboxListAfter js-checkbox-list-toggle js-all-events-select' %ul.checkboxList{:class => [@webhook.all_events? ? 'is-hidden' : '']} - for event in WebhookEvent::EVENTS - %li.checkboxList__item - .checkboxList__checkbox= check_box_tag "webhook[events][]", event, @webhook.events.include?(event), :id => "event_#{event}" + %li.checkboxList__item{:class => (event != 'MessageBounced' ? 'js-non-bounce-event' : '')} + .checkboxList__checkbox= check_box_tag "webhook[events][]", event, @webhook.events.include?(event), :id => "event_#{event}", :class => 'js-event-checkbox', :data => {:event => event} .checkboxList__label - = label_tag "event_#{event}", event, :class => 'checkboxList__actualLabel checkboxList__devEvent' + = label_tag "event_#{event}", event, :class => 'checkboxList__actualLabel checkboxList__devEvent js-event-label' %p.checkBoxList__text= t("webhook_events.#{event.underscore}") .fieldSetSubmit.buttonSet @@ -41,4 +52,3 @@ .fieldSetSubmit__delete - if f.object.persisted? = link_to "Delete Webhook", [organization, @server, @webhook], :remote => true, :class => 'button button--danger', :method => :delete, :data => {:confirm => "Are you sure you wish to delete this webhook?"} - diff --git a/app/views/webhooks/index.html.haml b/app/views/webhooks/index.html.haml index 0df057da2..0cc4f5b37 100644 --- a/app/views/webhooks/index.html.haml +++ b/app/views/webhooks/index.html.haml @@ -23,6 +23,10 @@ %span.label.label--green Enabled - else %span.label.label--red Disabled + - if webhook.output_style == 'listmonk' + %span.label.label--blue Listmonk + - else + %span.label.label--gray Postal .webhookList__bottom %p.webhookList__usageTime - if webhook.last_used_at diff --git a/db/migrate/20250616160443_add_output_style_to_webhooks.rb b/db/migrate/20250616160443_add_output_style_to_webhooks.rb new file mode 100644 index 000000000..4dd3075db --- /dev/null +++ b/db/migrate/20250616160443_add_output_style_to_webhooks.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOutputStyleToWebhooks < ActiveRecord::Migration[7.0] + + def change + add_column :webhooks, :output_style, :string, default: "postal" + end + +end diff --git a/db/schema.rb b/db/schema.rb index 9ab08fdb8..55087ae52 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_11_205229) do +ActiveRecord::Schema[7.0].define(version: 2025_06_16_160443) do create_table "additional_route_endpoints", id: :integer, charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.integer "route_id" t.string "endpoint_type" @@ -370,6 +370,7 @@ t.boolean "sign", default: true t.datetime "created_at" t.datetime "updated_at" + t.string "output_style", default: "postal" t.index ["server_id"], name: "index_webhooks_on_server_id" end diff --git a/doc/config/yaml.yml b/doc/config/yaml.yml index f3a735a9f..929272a5a 100644 --- a/doc/config/yaml.yml +++ b/doc/config/yaml.yml @@ -256,4 +256,10 @@ oidc: # The user info endpoint on the authorization server (only used when discovery is false) userinfo_endpoint: # The JWKS endpoint on the authorization server (only used when discovery is false) - jwks_uri: + jwks_uri: + +listmonk: + # Listmonk api username + api_user: + # Listmonk api key + api_key: diff --git a/lib/postal/config_schema.rb b/lib/postal/config_schema.rb index e3c6415d5..5cc7e09c1 100644 --- a/lib/postal/config_schema.rb +++ b/lib/postal/config_schema.rb @@ -589,6 +589,18 @@ module Postal description "The JWKS endpoint on the authorization server (only used when discovery is false)" end end + + group :listmonk do + string :api_user do + description "The api username of listmonk" + default "" + end + + string :api_key do + description "The api key of the listmonk user" + default "" + end + end end class << self diff --git a/spec/factories/webhook_factory.rb b/spec/factories/webhook_factory.rb index 10be33d79..17944a835 100644 --- a/spec/factories/webhook_factory.rb +++ b/spec/factories/webhook_factory.rb @@ -9,6 +9,7 @@ # enabled :boolean default(TRUE) # last_used_at :datetime # name :string(255) +# output_style :string(255) default("postal") # sign :boolean default(TRUE) # url :string(255) # uuid :string(255) @@ -26,5 +27,6 @@ name { "Example Webhook" } url { "https://example.com" } all_events { true } + output_style { "postal" } end end From 6931edda7b3fb9062f3b476456c80d4fdf5eaa20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Furkan=20=C3=96zt=C3=BCrk?= Date: Fri, 20 Jun 2025 11:49:08 +0300 Subject: [PATCH 5/5] tests for postal and listmonk webhook output styles --- .../services/webhook_delivery_service_spec.rb | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/spec/services/webhook_delivery_service_spec.rb b/spec/services/webhook_delivery_service_spec.rb index 4386c0fcd..17946e0fd 100644 --- a/spec/services/webhook_delivery_service_spec.rb +++ b/spec/services/webhook_delivery_service_spec.rb @@ -17,6 +17,159 @@ end describe "#call" do + context "with postal output style" do + it "sends a request to the webhook's url with postal format" do + service.call + expect(WebMock).to have_requested(:post, webhook.url).with({ + body: { + event: webhook_request.event, + timestamp: webhook_request.created_at.to_f, + payload: webhook_request.payload, + uuid: webhook_request.uuid + }.to_json, + headers: { + "Content-Type" => "application/json", + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i + } + }) + end + end + + context "with listmonk output style" do + let(:webhook) { create(:webhook, server: server, output_style: "listmonk") } + + context "for MessageBounced event" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, event: "MessageBounced", payload: { + bounce: { bounce_type: "hard" }, + original_message: { to: "test@example.com" } + }) + end + + it "sends a request with listmonk bounce format" do + service.call + expect(WebMock).to have_requested(:post, webhook.url).with({ + body: { + email: "test@example.com", + source: "postal", + type: "hard" + }.to_json, + headers: { + "Content-Type" => "application/json", + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i + } + }) + end + + context "with soft bounce" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, event: "MessageBounced", payload: { + bounce: { bounce_type: "soft" }, + original_message: { to: "soft-bounce@example.com" } + }) + end + + it "sends a request with type 'soft'" do + service.call + expect(WebMock).to have_requested(:post, webhook.url).with({ + body: { + email: "soft-bounce@example.com", + source: "postal", + type: "soft" + }.to_json, + headers: { + "Content-Type" => "application/json", + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i + } + }) + end + end + + context "with string keys in payload" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, event: "MessageBounced", payload: { + "bounce" => { "bounce_type" => "hard" }, + "original_message" => { "to" => "string-keys@example.com" } + }) + end + + it "handles string keys correctly" do + service.call + expect(WebMock).to have_requested(:post, webhook.url).with({ + body: { + email: "string-keys@example.com", + source: "postal", + type: "hard" + }.to_json, + headers: { + "Content-Type" => "application/json", + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i + } + }) + end + end + + context "with unknown bounce type" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, event: "MessageBounced", payload: { + bounce: { bounce_type: "unknown" }, + original_message: { to: "unknown-bounce@example.com" } + }) + end + + it "defaults to 'hard' type for unknown bounce types" do + service.call + expect(WebMock).to have_requested(:post, webhook.url).with({ + body: { + email: "unknown-bounce@example.com", + source: "postal", + type: "hard" + }.to_json, + headers: { + "Content-Type" => "application/json", + "X-Postal-Signature" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-256" => /\A[a-z0-9\/+]+=*\z/i, + "X-Postal-Signature-KID" => /\A[a-f0-9\/+]{64}\z/i + } + }) + end + end + end + + context "for unsupported event" do + let(:webhook_request) do + create(:webhook_request, :locked, webhook: webhook, event: "UnsupportedEvent", payload: {}) + end + + it "raises an error for unsupported events" do + expect { service.call }.to raise_error(Postal::Error, "Unsupported event 'UnsupportedEvent' for output style 'listmonk'") + end + + it "does not send a request" do + expect { service.call }.to raise_error(Postal::Error) + expect(WebMock).not_to have_requested(:post, webhook.url) + end + + it "does not create any webhook records" do + expect { service.call }.to raise_error(Postal::Error) + expect(server.message_db.webhooks.list(1)[:total]).to eq(0) + end + + it "does not delete the webhook request" do + expect { service.call }.to raise_error(Postal::Error) + expect { webhook_request.reload }.not_to raise_error + end + end + end + it "sends a request to the webhook's url" do service.call expect(WebMock).to have_requested(:post, webhook.url).with({