From ed3c7c324b9a0d1225afc3d07436988175477a0b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 16 Mar 2026 17:09:38 +0200 Subject: [PATCH 01/10] adjust save dynamic --- app/javascript/template_builder/builder.vue | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 98622025..220248fb 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -2735,10 +2735,13 @@ export default { } else { this.isSaving = true - this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update()) + const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic) + + dynamicDocumentRefs.map((ref) => ref.update()) + this.rebuildVariablesSchema({ disable: false }) - const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody()) + const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody()) Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => { window.Turbo.visit(`/templates/${this.template.id}`) @@ -3028,6 +3031,8 @@ export default { } else { dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable }) } + } else { + this.template.variables_schema = {} } } } From 36d59c40a7d6864e38b8ff05a374cc17acbac033 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 18 Mar 2026 08:36:36 +0200 Subject: [PATCH 02/10] add template update event --- app/controllers/templates_restore_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/templates_restore_controller.rb b/app/controllers/templates_restore_controller.rb index 9072e84d..d6d0505e 100644 --- a/app/controllers/templates_restore_controller.rb +++ b/app/controllers/templates_restore_controller.rb @@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController def create @template.update!(archived_at: nil) + WebhookUrls.enqueue_events(@template, 'template.updated') + redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived') end end From 7309581b756750605968d653b0a740e899946471 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 18 Mar 2026 08:46:19 +0200 Subject: [PATCH 03/10] add template archived event --- app/controllers/api/templates_controller.rb | 3 + app/controllers/templates_controller.rb | 2 + app/controllers/webhook_events_controller.rb | 2 +- ...d_template_archived_webhook_request_job.rb | 38 +++++++ app/models/webhook_url.rb | 1 + lib/webhook_urls.rb | 3 +- ...plate_archived_webhook_request_job_spec.rb | 100 ++++++++++++++++++ 7 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 app/jobs/send_template_archived_webhook_request_job.rb create mode 100644 spec/jobs/send_template_archived_webhook_request_job_spec.rb diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb index df4bcee7..87b756ea 100644 --- a/app/controllers/api/templates_controller.rb +++ b/app/controllers/api/templates_controller.rb @@ -69,6 +69,7 @@ module Api SearchEntries.enqueue_reindex(@template) WebhookUrls.enqueue_events(@template, 'template.updated') + WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true render json: @template.as_json(only: %i[id updated_at]) end @@ -78,6 +79,8 @@ module Api @template.destroy! else @template.update!(archived_at: Time.current) + + WebhookUrls.enqueue_events(@template, 'template.archived') end render json: @template.as_json(only: %i[id archived_at]) diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb index b9496320..e402f53a 100644 --- a/app/controllers/templates_controller.rb +++ b/app/controllers/templates_controller.rb @@ -84,6 +84,8 @@ class TemplatesController < ApplicationController else @template.update!(archived_at: Time.current) + WebhookUrls.enqueue_events(@template, 'template.archived') + I18n.t('template_has_been_archived') end diff --git a/app/controllers/webhook_events_controller.rb b/app/controllers/webhook_events_controller.rb index 55bcb4ea..62ef534c 100644 --- a/app/controllers/webhook_events_controller.rb +++ b/app/controllers/webhook_events_controller.rb @@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController Submissions::SerializeForApi.call(@webhook_event.record) when 'template.created', 'template.updated' Templates::SerializeForApi.call(@webhook_event.record) - when 'submission.archived' + when 'submission.archived', 'template.archived' @webhook_event.record.as_json(only: %i[id archived_at]) end end diff --git a/app/jobs/send_template_archived_webhook_request_job.rb b/app/jobs/send_template_archived_webhook_request_job.rb new file mode 100644 index 00000000..1b1c7c92 --- /dev/null +++ b/app/jobs/send_template_archived_webhook_request_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class SendTemplateArchivedWebhookRequestJob + include Sidekiq::Job + + sidekiq_options queue: :webhooks + + MAX_ATTEMPTS = 10 + + def perform(params = {}) + template = Template.find_by(id: params['template_id']) + + return unless template + + webhook_url = WebhookUrl.find_by(id: params['webhook_url_id']) + + return unless webhook_url + + attempt = params['attempt'].to_i + + return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived') + + resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived', + event_uuid: params['event_uuid'], + record: template, + attempt:, + data: template.as_json(only: %i[id archived_at])) + + if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && + (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) + SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { + **params, + 'attempt' => attempt + 1, + 'last_status' => resp&.status.to_i + }) + end + end +end diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index b0de0f58..6e92161d 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord submission.archived template.created template.updated + template.archived ].freeze belongs_to :account diff --git a/lib/webhook_urls.rb b/lib/webhook_urls.rb index 50ab4785..242c274c 100644 --- a/lib/webhook_urls.rb +++ b/lib/webhook_urls.rb @@ -11,7 +11,8 @@ module WebhookUrls 'submission.expired' => SendSubmissionExpiredWebhookRequestJob, 'submission.archived' => SendSubmissionArchivedWebhookRequestJob, 'template.created' => SendTemplateCreatedWebhookRequestJob, - 'template.updated' => SendTemplateUpdatedWebhookRequestJob + 'template.updated' => SendTemplateUpdatedWebhookRequestJob, + 'template.archived' => SendTemplateArchivedWebhookRequestJob }.freeze EVENT_TYPE_ID_KEYS = { diff --git a/spec/jobs/send_template_archived_webhook_request_job_spec.rb b/spec/jobs/send_template_archived_webhook_request_job_spec.rb new file mode 100644 index 00000000..d40f68b5 --- /dev/null +++ b/spec/jobs/send_template_archived_webhook_request_job_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +RSpec.describe SendTemplateArchivedWebhookRequestJob do + let(:account) { create(:account) } + let(:user) { create(:user, account:) } + let(:template) { create(:template, account:, author: user) } + let(:webhook_url) { create(:webhook_url, account:, events: ['template.archived']) } + + before do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + end + + describe '#perform' do + around do |example| + freeze_time { example.run } + end + + before do + stub_request(:post, webhook_url.url).to_return(status: 200) + end + + it 'sends a webhook request' do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: { + 'event_type' => 'template.archived', + 'timestamp' => /.*/, + 'data' => template.reload.as_json(only: %i[id archived_at]) + }, + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.com Webhook' + } + ).once + end + + it 'sends a webhook request with the secret' do + webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' }) + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).to have_requested(:post, webhook_url.url).with( + body: { + 'event_type' => 'template.archived', + 'timestamp' => /.*/, + 'data' => template.reload.as_json(only: %i[id archived_at]) + }, + headers: { + 'Content-Type' => 'application/json', + 'User-Agent' => 'DocuSeal.com Webhook', + 'X-Secret-Header' => 'secret_value' + } + ).once + end + + it "doesn't send a webhook request if the event is not in the webhook's events" do + webhook_url.update!(events: ['template.updated']) + + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid) + + expect(WebMock).not_to have_requested(:post, webhook_url.url) + end + + it 'sends again if the response status is 400 or higher' do + stub_request(:post, webhook_url.url).to_return(status: 401) + + event_uuid = SecureRandom.uuid + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => event_uuid) + end.to change(described_class.jobs, :size).by(1) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + + args = described_class.jobs.last['args'].first + + expect(args['attempt']).to eq(1) + expect(args['last_status']).to eq(401) + expect(args['event_uuid']).to eq(event_uuid) + expect(args['webhook_url_id']).to eq(webhook_url.id) + expect(args['template_id']).to eq(template.id) + end + + it "doesn't send again if the max attempts is reached" do + stub_request(:post, webhook_url.url).to_return(status: 401) + + expect do + described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, + 'event_uuid' => SecureRandom.uuid, 'attempt' => 11) + end.not_to change(described_class.jobs, :size) + + expect(WebMock).to have_requested(:post, webhook_url.url).once + end + end +end From 1a8f048aaaa9b0846029b697c4538a4adc8d0680 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 18 Mar 2026 09:17:33 +0200 Subject: [PATCH 04/10] update devise --- Gemfile.lock | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index ae90555e..33328f99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,16 +171,16 @@ GEM irb (~> 1.10) reline (>= 0.3.8) declarative (0.0.20) - devise (4.9.4) + devise (5.0.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0) + railties (>= 7.0) responders warden (~> 1.2.3) - devise-two-factor (6.3.1) - activesupport (>= 7.0, < 8.2) - devise (>= 4.0, < 5.0) - railties (>= 7.0, < 8.2) + devise-two-factor (6.4.0) + activesupport (>= 7.2, < 8.2) + devise (>= 4.0, < 6.0) + railties (>= 7.2, < 8.2) rotp (~> 6.0) diff-lcs (1.6.2) digest-crc (0.7.0) @@ -189,7 +189,7 @@ GEM dotenv (3.2.0) drb (2.2.3) email_typo (0.2.3) - erb (6.0.1) + erb (6.0.2) erb_lint (0.9.0) activesupport better_html (>= 2.0.1) @@ -279,12 +279,13 @@ GEM mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) io-console (0.8.2) - irb (1.16.0) + irb (1.17.0) pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.18.1) + json (2.19.1) jwt (3.1.2) base64 language_server-protocol (3.17.0.5) @@ -306,7 +307,7 @@ GEM activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.25.0) + loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.9.0) @@ -321,7 +322,8 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (6.0.1) + minitest (6.0.2) + drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) multi_json (1.19.1) @@ -387,7 +389,7 @@ GEM pretender (0.6.0) actionpack (>= 7.1) prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) @@ -429,8 +431,8 @@ GEM activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) @@ -446,7 +448,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (7.1.0) + rdoc (7.2.0) erb psych (>= 4.0.0) tsort @@ -601,7 +603,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yaml (0.4.0) - zeitwerk (2.7.4) + zeitwerk (2.7.5) PLATFORMS aarch64-linux From 342b64d5024805e0042f928dced0751041174578 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Wed, 18 Mar 2026 09:52:58 +0200 Subject: [PATCH 05/10] add translations --- config/locales/i18n.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index d40fc480..0b91d8fb 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -914,6 +914,9 @@ en: &en connect_to_docuseal_mcp: Connect to DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Add the following to your MCP client configuration works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Works with Claude Desktop, Cursor, Windsurf, VS Code, and any MCP-compatible client. + your_email_address_has_been_changed: Your email address has been changed + the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email. devise: confirmations: confirmed: Your email address has been successfully confirmed. @@ -1938,6 +1941,9 @@ es: &es connect_to_docuseal_mcp: Conectar a DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Agregue lo siguiente a la configuración de su cliente MCP works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona con Claude Desktop, Cursor, Windsurf, VS Code y cualquier cliente compatible con MCP. + your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada + the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico. devise: confirmations: confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente. @@ -2963,6 +2969,9 @@ it: &it connect_to_docuseal_mcp: Connetti a DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Aggiungi quanto segue alla configurazione del tuo client MCP works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funziona con Claude Desktop, Cursor, Windsurf, VS Code e qualsiasi client compatibile con MCP. + your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato + the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email. devise: confirmations: confirmed: Il tuo indirizzo email è stato confermato con successo. @@ -3984,6 +3993,9 @@ fr: &fr connect_to_docuseal_mcp: Se connecter à DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Ajoutez ce qui suit à la configuration de votre client MCP works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Fonctionne avec Claude Desktop, Cursor, Windsurf, VS Code et tout client compatible MCP. + your_email_address_has_been_changed: Votre adresse e-mail a été modifiée + the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}." + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail. devise: confirmations: confirmed: Votre adresse e-mail a été confirmée avec succès. @@ -5008,6 +5020,9 @@ pt: &pt connect_to_docuseal_mcp: Conectar ao DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Adicione o seguinte à configuração do seu cliente MCP works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona com Claude Desktop, Cursor, Windsurf, VS Code e qualquer cliente compatível com MCP. + your_email_address_has_been_changed: Seu endereço de e-mail foi alterado + the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail. devise: confirmations: confirmed: Seu endereço de e-mail foi confirmado com sucesso. @@ -6032,6 +6047,9 @@ de: &de connect_to_docuseal_mcp: Mit DocuSeal MCP verbinden add_the_following_to_your_mcp_client_configuration: Fügen Sie Folgendes zu Ihrer MCP-Client-Konfiguration hinzu works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funktioniert mit Claude Desktop, Cursor, Windsurf, VS Code und jedem MCP-kompatiblen Client. + your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert + the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten. devise: confirmations: confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt. @@ -7441,6 +7459,9 @@ nl: &nl connect_to_docuseal_mcp: Verbinden met DocuSeal MCP add_the_following_to_your_mcp_client_configuration: Voeg het volgende toe aan uw MCP-clientconfiguratie works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Werkt met Claude Desktop, Cursor, Windsurf, VS Code en elke MCP-compatibele client. + your_email_address_has_been_changed: Je e-mailadres is gewijzigd + the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van je account is gewijzigd naar %{new_email}. + if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als je deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden. devise: confirmations: confirmed: Je e-mailadres is succesvol bevestigd. From 2ba903e170a9d96d56a77ec29f3f0a31aae61c2b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 18 Mar 2026 10:18:28 +0200 Subject: [PATCH 06/10] fix specs --- spec/system/sign_in_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/system/sign_in_spec.rb b/spec/system/sign_in_spec.rb index df0e4f02..6c194207 100644 --- a/spec/system/sign_in_spec.rb +++ b/spec/system/sign_in_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Sign In' do fill_in 'Password', with: 'wrong_password' click_button 'Sign In' - expect(page).to have_content('Invalid Email or password') + expect(page).to have_content('Invalid email or password') expect(page).not_to have_content('Document Templates') end end @@ -51,7 +51,7 @@ RSpec.describe 'Sign In' do fill_in 'Two-Factor Code from Authenticator App', with: '123456' click_button 'Sign In' - expect(page).to have_content('Invalid Email or password') + expect(page).to have_content('Invalid email or password') expect(page).not_to have_content('Document Templates') end end From 608191183ae3d3c8707ec8e8a9a8f67060a458ab Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 21 Mar 2026 08:27:19 +0200 Subject: [PATCH 07/10] update deps --- Gemfile | 2 +- Gemfile.lock | 28 +++++++++++----------------- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index 74ba1afb..d8503573 100644 --- a/Gemfile +++ b/Gemfile @@ -43,7 +43,7 @@ gem 'shakapacker' gem 'sidekiq' gem 'sqlite3', require: false gem 'strip_attributes' -gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false +gem 'trilogy', require: false gem 'turbo-rails' gem 'twitter_cldr', require: false gem 'tzinfo-data' diff --git a/Gemfile.lock b/Gemfile.lock index 33328f99..a20c37db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -GIT - remote: https://github.com/trilogy-libraries/trilogy.git - revision: 3963d490459df7a2b5bedb42424c3285f25eab22 - glob: contrib/ruby/*.gemspec - specs: - trilogy (2.10.0) - bigdecimal - GEM remote: https://rubygems.org/ specs: @@ -116,7 +108,7 @@ GEM cgi rexml base64 (0.3.0) - bcrypt (3.1.21) + bcrypt (3.1.22) better_html (2.2.0) actionview (>= 7.0) activesupport (>= 7.0) @@ -285,7 +277,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.19.1) + json (2.19.2) jwt (3.1.2) base64 language_server-protocol (3.17.0.5) @@ -339,17 +331,17 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.1-aarch64-linux-gnu) + nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-aarch64-linux-musl) + nokogiri (1.19.2-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.1-arm64-darwin) + nokogiri (1.19.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-gnu) + nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.1-x86_64-linux-musl) + nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) - numo-narray-alt (0.9.13) + numo-narray-alt (0.10.3) oj (3.16.13) bigdecimal (>= 3.0) ostruct (>= 0.2) @@ -564,6 +556,8 @@ GEM thor (1.5.0) timeout (0.6.0) trailblazer-option (0.1.2) + trilogy (2.10.0) + bigdecimal tsort (0.2.0) turbo-rails (2.0.21) actionpack (>= 7.1.0) @@ -671,7 +665,7 @@ DEPENDENCIES simplecov sqlite3 strip_attributes - trilogy! + trilogy turbo-rails twitter_cldr tzinfo-data From b11775ebda55f00c8fc545598b6d712345e22393 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 22 Mar 2026 18:01:12 +0200 Subject: [PATCH 08/10] remove premailer --- Gemfile | 2 +- Gemfile.lock | 13 +-- app/mailers/application_mailer.rb | 2 + lib/html_to_plain_text.rb | 119 ++++++++++++++++++++++++++ lib/html_to_plain_text_interceptor.rb | 65 ++++++++++++++ 5 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 lib/html_to_plain_text.rb create mode 100644 lib/html_to_plain_text_interceptor.rb diff --git a/Gemfile b/Gemfile index d8503573..4e2fd78e 100644 --- a/Gemfile +++ b/Gemfile @@ -4,6 +4,7 @@ source 'https://rubygems.org' ruby '4.0.1' +gem 'addressable' gem 'arabic-letter-connector', require: false gem 'aws-sdk-s3', require: false gem 'aws-sdk-secretsmanager', require: false @@ -28,7 +29,6 @@ gem 'oj' gem 'onnxruntime', require: false gem 'pagy' gem 'pg', require: false -gem 'premailer-rails' gem 'pretender' gem 'puma', require: false gem 'rack' diff --git a/Gemfile.lock b/Gemfile.lock index a20c37db..ce504fee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,8 +150,6 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.21.1) - addressable csv (3.3.5) csv-safe (3.3.1) csv (~> 3.0) @@ -264,7 +262,6 @@ GEM geom2d (~> 0.4, >= 0.4.1) openssl (>= 2.2.1) strscan (>= 3.1.2) - htmlentities (4.4.2) i18n (1.14.8) concurrent-ruby (~> 1.0) image_processing (1.14.0) @@ -370,14 +367,6 @@ GEM pg (1.6.3-x86_64-linux-musl) pp (0.6.3) prettyprint - premailer (1.27.0) - addressable - css_parser (>= 1.19.0) - htmlentities (>= 4.0.0) - premailer-rails (1.12.0) - actionmailer (>= 3) - net-smtp - premailer (~> 1.7, >= 1.7.9) pretender (0.6.0) actionpack (>= 7.1) prettyprint (0.2.0) @@ -607,6 +596,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + addressable annotaterb arabic-letter-connector aws-sdk-s3 @@ -643,7 +633,6 @@ DEPENDENCIES onnxruntime pagy pg - premailer-rails pretender pry-rails puma diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 945994ed..c5ed556d 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base layout 'mailer' register_interceptor ActionMailerConfigsInterceptor + register_interceptor HtmlToPlainTextInterceptor + register_preview_interceptor HtmlToPlainTextInterceptor register_observer ActionMailerEventsObserver diff --git a/lib/html_to_plain_text.rb b/lib/html_to_plain_text.rb new file mode 100644 index 00000000..1001f40c --- /dev/null +++ b/lib/html_to_plain_text.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module HtmlToPlainText + module_function + + def call(html, line_length = 65) + return '' if html.nil? || html.strip.empty? + + cleaned = html.gsub(%r{.*?}m, '') + + doc = Nokogiri::HTML.fragment(cleaned) + + doc.css('script').each(&:remove) + + result = process_nodes(doc, line_length) + + result.gsub!(/\r\n?/, "\n") + result.gsub!(/[ \t]*\u00A0+[ \t]*/, ' ') + result.gsub!(/\n[ \t]+/, "\n") + result.gsub!(/[ \t]+\n/, "\n") + result.gsub!(/\n{3,}/, "\n\n") + + result = word_wrap(result, line_length) + + result.gsub!(/\(([ \n])(http[^)]+)([\n ])\)/) do + "#{"\n" if ::Regexp.last_match(1) == "\n"}( #{::Regexp.last_match(2)} )#{"\n" if ::Regexp.last_match(3) == "\n"}" + end + + result.strip + end + + def process_nodes(node, line_length) + result = +'' + + node.children.each do |child| + case child + when Nokogiri::XML::Text + result << child.text + when Nokogiri::XML::Comment + next + when Nokogiri::XML::Element + result << process_element(child, line_length) + end + end + + result + end + + def process_element(node, line_length) + case node.name + when 'br' + "\n" + when 'p', 'div' + inner = process_nodes(node, line_length) + inner.strip.empty? ? '' : "#{inner}\n\n" + when 'img' + node['alt'] || '' + when 'a' + process_link(node, line_length) + when /\Ah([1-6])\z/ + process_heading(node, ::Regexp.last_match(1).to_i, line_length) + when 'li' + inner = process_nodes(node, line_length) + "* #{inner.strip}\n" + else + process_nodes(node, line_length) + end + end + + def process_link(node, line_length) + text = process_nodes(node, line_length).strip + return '' if text.empty? + + href = node['href'] + href = href.sub(/\Amailto:/i, '') if href + + if href.nil? || text.casecmp(href.strip) == 0 + text + else + "#{text} ( #{href.strip} )" + end + end + + def process_heading(node, level, line_length) + text = +'' + node.children.each do |child| + text << if child.name == 'br' + "\n" + else + child.text + end + end + text.strip! + + hlength = text.each_line.map { |l| l.strip.length }.max || 0 + hlength = line_length if hlength > line_length + + decorated = case level + when 1 + "#{'*' * hlength}\n#{text}\n#{'*' * hlength}" + when 2 + "#{'-' * hlength}\n#{text}\n#{'-' * hlength}" + else + "#{text}\n#{'-' * hlength}" + end + + "\n\n#{decorated}\n\n" + end + + def word_wrap(txt, line_length) + txt.split("\n").map do |line| + if line.length > line_length + line.gsub(/(.{1,#{line_length}})(\s+|$)/, "\\1\n").strip + else + line + end + end.join("\n") + end +end diff --git a/lib/html_to_plain_text_interceptor.rb b/lib/html_to_plain_text_interceptor.rb new file mode 100644 index 00000000..9041d8ad --- /dev/null +++ b/lib/html_to_plain_text_interceptor.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module HtmlToPlainTextInterceptor + module_function + + def delivering_email(message) + process(message) + end + + def previewing_email(message) + process(message) + end + + def process(message) + return message unless html_part(message) + return message if message.text_part + + add_text_part(message) + + message + end + + def add_text_part(message) + html = html_part(message).decoded + text = HtmlToPlainText.call(html) + + text_part = Mail::Part.new do + content_type 'text/plain; charset=UTF-8' + body text + end + + if pure_html_message?(message) + message.body = nil + message.content_type = 'multipart/alternative' + message.add_part(text_part) + message.add_part(Mail::Part.new do + content_type 'text/html; charset=UTF-8' + body html + end) + else + alternative = Mail::Part.new(content_type: 'multipart/alternative') + alternative.add_part(text_part) + alternative.add_part(message.html_part) + replace_part(message.parts, message.html_part, alternative) + end + end + + def pure_html_message?(message) + message.content_type.to_s.include?('text/html') + end + + def html_part(message) + pure_html_message?(message) ? message : message.html_part + end + + def replace_part(parts, old_part, new_part) + if (index = parts.index(old_part)) + parts[index] = new_part + else + parts.each do |part| + replace_part(part.parts, old_part, new_part) if part.respond_to?(:parts) + end + end + end +end From 8d7c3f0e260a975792762c4df91b7595529de53c Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 23 Mar 2026 09:27:12 +0200 Subject: [PATCH 09/10] fix generation --- lib/submissions/generate_result_attachments.rb | 5 +++-- lib/templates/build_annotations.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index ab5a840a..b538f6bc 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -236,9 +236,10 @@ module Submissions page[:Annots] ||= [] page[:Annots] = page[:Annots].try(:reject) do |e| - next if e.is_a?(Integer) || e.is_a?(Symbol) + next if e.is_a?(Integer) || e.is_a?(Symbol) || e.is_a?(HexaPDF::PDFArray) - e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field') + e.present? && e[:A] && !e[:A].is_a?(HexaPDF::PDFArray) && + e[:A][:URI].to_s.starts_with?('file:///docuseal_field') end || page[:Annots] width = page.box.width diff --git a/lib/templates/build_annotations.rb b/lib/templates/build_annotations.rb index 9a933f99..8ce566d1 100644 --- a/lib/templates/build_annotations.rb +++ b/lib/templates/build_annotations.rb @@ -10,7 +10,7 @@ module Templates pdf.pages.flat_map.with_index do |page, index| (page[:Annots] || []).filter_map do |annot| next if annot.blank? - next if annot.is_a?(Integer) || annot.is_a?(Symbol) + next if annot.is_a?(Integer) || annot.is_a?(Symbol) || annot.is_a?(HexaPDF::PDFArray) next if annot[:A].blank? || annot[:A][:URI].blank? next unless annot[:Subtype] == :Link next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://') From e3dfb0ae838c955bfc0a5eb7cf404937ada35993 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 23 Mar 2026 10:02:54 +0200 Subject: [PATCH 10/10] add SMTP_ENABLE_TLS --- config/environments/production.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/environments/production.rb b/config/environments/production.rb index 85b77247..8a14ab05 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -88,6 +88,8 @@ Rails.application.configure do openssl_verify_mode: ENV['SMTP_SSL_VERIFY'] == 'false' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER, authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil, enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false', + ssl: ENV['SMTP_ENABLE_SSL'] == 'true', + tls: ENV['SMTP_ENABLE_TLS'] == 'true', open_timeout: ENV.fetch('SMTP_OPEN_TIMEOUT', '15').to_i, read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i }.compact