From 6ed5b5d35d698d89dbd627f6f49bb38e6017ea1c Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 29 Jun 2026 12:30:10 +0300 Subject: [PATCH] extract email assets --- app/mailers/submitter_mailer.rb | 2 +- app/models/account.rb | 1 + app/models/email_message.rb | 9 +++ app/models/email_message_asset.rb | 30 +++++++++ .../submissions/_send_email_base.html.erb | 4 +- ...60628120000_create_email_message_assets.rb | 14 +++++ db/schema.rb | 12 +++- lib/email_messages.rb | 61 ++++++++++++++++++- 8 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 app/models/email_message_asset.rb create mode 100644 db/migrate/20260628120000_create_email_message_assets.rb diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb index 0adbb9c6..ebb8f224 100644 --- a/app/mailers/submitter_mailer.rb +++ b/app/mailers/submitter_mailer.rb @@ -16,7 +16,7 @@ class SubmitterMailer < ApplicationMailer template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {} - @body = @email_message&.body.presence || + @body = @email_message&.normalized_body.presence || template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence || @submitter.template&.preferences&.dig('request_email_body').presence diff --git a/app/models/account.rb b/app/models/account.rb index d3d53d0c..3415712e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -24,6 +24,7 @@ class Account < ApplicationRecord has_many :encrypted_configs, dependent: :destroy has_many :account_configs, dependent: :destroy has_many :email_messages, dependent: :destroy + has_many :email_message_assets, dependent: :destroy has_many :templates, dependent: :destroy has_many :template_folders, dependent: :destroy has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, diff --git a/app/models/email_message.rb b/app/models/email_message.rb index 25156d6f..5fdc1021 100644 --- a/app/models/email_message.rb +++ b/app/models/email_message.rb @@ -33,6 +33,15 @@ class EmailMessage < ApplicationRecord before_validation :set_sha1, on: :create + def normalized_body + @normalized_body ||= + if body&.include?(EmailMessages::ASSET_PREFIX) + EmailMessages.rebuild_body_with_assets(account_id, body) + else + body + end + end + def set_sha1 self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json) end diff --git a/app/models/email_message_asset.rb b/app/models/email_message_asset.rb new file mode 100644 index 00000000..6b64dd41 --- /dev/null +++ b/app/models/email_message_asset.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: email_message_assets +# +# id :bigint not null, primary key +# data :text not null +# sha1 :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_email_message_assets_on_account_id_and_sha1 (account_id,sha1) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (account_id => accounts.id) +# +class EmailMessageAsset < ApplicationRecord + belongs_to :account + + before_validation :set_sha1, on: :create + + def set_sha1 + self.sha1 = Digest::SHA1.hexdigest(data.to_s) + end +end diff --git a/app/views/submissions/_send_email_base.html.erb b/app/views/submissions/_send_email_base.html.erb index ede68994..6b035cf7 100644 --- a/app/views/submissions/_send_email_base.html.erb +++ b/app/views/submissions/_send_email_base.html.erb @@ -56,7 +56,7 @@
<%= f.label :message, t('body'), class: 'label' %> <% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %> - <%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %> + <%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %> <% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<%= ff.label :message, t('body'), class: 'label' %> - <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %> + <%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<% end %> diff --git a/db/migrate/20260628120000_create_email_message_assets.rb b/db/migrate/20260628120000_create_email_message_assets.rb new file mode 100644 index 00000000..1f69247d --- /dev/null +++ b/db/migrate/20260628120000_create_email_message_assets.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateEmailMessageAssets < ActiveRecord::Migration[8.1] + def change + create_table :email_message_assets do |t| + t.references :account, null: false, foreign_key: true, index: false + t.text :data, null: false + t.string :sha1, null: false + t.timestamps + end + + add_index :email_message_assets, %i[account_id sha1], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7b65ff4e..3d2012a4 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[8.1].define(version: 2026_06_27_083558) do +ActiveRecord::Schema[8.1].define(version: 2026_06_28_120000) do # These are extensions that must be enabled in order to support this database enable_extension "btree_gin" enable_extension "pg_catalog.plpgsql" @@ -214,6 +214,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do t.index ["message_id"], name: "index_email_events_on_message_id" end + create_table "email_message_assets", force: :cascade do |t| + t.bigint "account_id", null: false + t.datetime "created_at", null: false + t.text "data", null: false + t.string "sha1", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "sha1"], name: "index_email_message_assets_on_account_id_and_sha1", unique: true + end + create_table "email_messages", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "author_id", null: false @@ -579,6 +588,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_27_083558) do add_foreign_key "dynamic_document_versions", "dynamic_documents" add_foreign_key "dynamic_documents", "templates" add_foreign_key "email_events", "accounts" + add_foreign_key "email_message_assets", "accounts" add_foreign_key "email_messages", "accounts" add_foreign_key "email_messages", "users", column: "author_id" add_foreign_key "encrypted_configs", "accounts" diff --git a/lib/email_messages.rb b/lib/email_messages.rb index 7ece21b8..08436730 100644 --- a/lib/email_messages.rb +++ b/lib/email_messages.rb @@ -1,13 +1,70 @@ # frozen_string_literal: true module EmailMessages + MIN_BODY_SIZE = 2.kilobytes + MIN_ASSET_SIZE = 256.bytes + STYLE_REGEXP = %r{]*>.*?(?:\s*]*>.*?)*}mi + BASE64_REGEXP = %r{(data:[^,]*;base64,)([A-Za-z0-9+/=]+)} + ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP) + ASSET_PREFIX = '[[asset:' + PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/ + module_function def find_or_create_for_account_user(account, user, subject, body) subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank? - message = account.email_messages.new(author: user, subject:, body:).tap(&:validate) + body, assets = maybe_extract_assets(account, body) + + new_message = account.email_messages.new(author: user, subject:, body:).tap(&:validate) + + message = account.email_messages.find_by(sha1: new_message.sha1) + + message ||= new_message.tap do |m| + m.save!(validate: false) + + save_new_assets!(account, assets) + end + + message + end + + def save_new_assets!(account, assets) + return if assets.blank? + + existing_assets_sha1 = account.email_message_assets.where(sha1: assets.map(&:sha1)).pluck(:sha1) + + assets.each do |asset| + asset.save!(validate: false) if existing_assets_sha1.exclude?(asset.sha1) + rescue ActiveRecord::RecordNotUnique + nil + end + end + + def maybe_extract_assets(account, body) + return [body, []] if body.blank? || body.bytesize < MIN_BODY_SIZE + + assets_index = {} + + result = body.gsub(ASSET_REGEXP) do + match = Regexp.last_match + prefix, data = match[1] ? [match[1], match[2]] : ['', match[0]] + + next match[0] if data.blank? || data.bytesize < MIN_ASSET_SIZE + + asset = account.email_message_assets.new(data:).tap(&:validate) + assets_index[asset.sha1] = asset + + "#{prefix}#{ASSET_PREFIX}#{asset.sha1}]]" + end + + [result, assets_index.values] + end + + def rebuild_body_with_assets(account_id, body) + shas = body.scan(PLACEHOLDER_REGEXP).flatten.uniq + data = EmailMessageAsset.where(account_id:, sha1: shas).pluck(:sha1, :data).to_h - account.email_messages.find_by(sha1: message.sha1) || message.tap { |m| m.save!(validate: false) } + body.gsub(PLACEHOLDER_REGEXP) { data[Regexp.last_match(1)] || Regexp.last_match(0) } end end