From 410e9460fd099e215672a6f18f6810b4b569ef06 Mon Sep 17 00:00:00 2001 From: sdi Date: Tue, 11 Aug 2026 11:12:14 +0300 Subject: [PATCH 1/2] Add configurable invoice email templates --- app/admin/billing/invoice_email_templates.rb | 91 ++++++++++++ app/admin/logs/email_logs.rb | 3 + app/domain/contact_email_sender.rb | 22 ++- app/domain/invoice_mail.rb | 131 ++++++++++++++++++ app/mailers/yeti_mail.rb | 7 + app/models/billing/invoice_document.rb | 12 +- app/models/billing/invoice_email_template.rb | 57 ++++++++ app/models/billing/notification_template.rb | 41 +----- app/models/concerns/liquid_template.rb | 57 ++++++++ app/models/log/email_log.rb | 1 + .../billing/invoice_email_template_policy.rb | 12 ++ app/services/billing_invoice/invoice_data.rb | 18 ++- ...810120000_create_invoice_email_template.rb | 88 ++++++++++++ ...260810120001_add_text_msg_to_email_logs.rb | 18 +++ db/seeds/main/billing.sql | 66 +++++++++ db/structure.sql | 26 +++- spec/domain/contact_email_sender_spec.rb | 18 ++- spec/domain/invoice_mail_spec.rb | 113 +++++++++++++++ spec/factories/log/email_logs.rb | 1 + .../manage_template_spec.rb | 95 +++++++++++++ spec/mailers/yeti_mail_spec.rb | 56 +++++++- .../billing/invoice_email_template_spec.rb | 123 ++++++++++++++++ spec/services/billing_invoice/approve_spec.rb | 15 ++ .../billing_invoice/invoice_data_spec.rb | 21 +++ 24 files changed, 1032 insertions(+), 60 deletions(-) create mode 100644 app/admin/billing/invoice_email_templates.rb create mode 100644 app/domain/invoice_mail.rb create mode 100644 app/models/billing/invoice_email_template.rb create mode 100644 app/models/concerns/liquid_template.rb create mode 100644 app/policies/billing/invoice_email_template_policy.rb create mode 100644 db/migrate/20260810120000_create_invoice_email_template.rb create mode 100644 db/migrate/20260810120001_add_text_msg_to_email_logs.rb create mode 100644 spec/domain/invoice_mail_spec.rb create mode 100644 spec/features/billing/invoice_email_templates/manage_template_spec.rb create mode 100644 spec/models/billing/invoice_email_template_spec.rb diff --git a/app/admin/billing/invoice_email_templates.rb b/app/admin/billing/invoice_email_templates.rb new file mode 100644 index 000000000..6e17d10ee --- /dev/null +++ b/app/admin/billing/invoice_email_templates.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +ActiveAdmin.register Billing::InvoiceEmailTemplate, as: 'InvoiceEmailTemplate' do + menu parent: %w[Billing Settings], label: 'Invoice email template', priority: 92 + config.batch_actions = false + config.filters = false + + # One system-wide row, so there is nothing to list, create or delete — the + # menu entry behaves like a settings page. + actions :index, :show, :edit, :update + + permit_params :subject, :html_body, :text_body + + controller do + def index + redirect_to invoice_email_template_path(1) + end + end + + # Both parts in one view: an email that looks right in HTML and unreadable in + # plain text is only half checked. + member_action :preview, method: :get do + assigns = InvoiceMail.sample_assigns + text_part = ERB::Util.html_escape(resource.render_text_body(assigns)) + html_part = SandboxedEmailFrame.render(resource.render_html_body(assigns), style: 'width:100%;height:70vh') + + render html: <<~HTML.html_safe, layout: false +
+

Plain text part

+
#{text_part}
+

HTML part

+ #{html_part} +
+ HTML + rescue StandardError => e + flash[:warning] = "Template cannot be rendered: #{e.message}" + redirect_to action: :show + end + + action_item :preview, only: [:show] do + link_to 'Preview', preview_invoice_email_template_path(resource), target: '_blank', rel: 'noopener' + end + + show do |t| + attributes_table do + row :subject + end + + panel 'Plain text body' do + pre(style: 'white-space: pre-wrap; word-break: break-word;') { t.text_body } + end + + panel 'HTML body' do + # Rendered as
; highlight.js (loaded in
+      # active_admin.js) highlights it on load. The django/jinja grammar
+      # sub-highlights the HTML and the {{ }} / {% %} tags — liquid shares that
+      # syntax, and django is the grammar already in the bundle. Arbre
+      # HTML-escapes the String, so it is shown as source, not rendered.
+      pre(style: 'white-space: pre-wrap; word-break: break-word;') do
+        code(class: 'language-django') { t.html_body }
+      end
+    end
+
+    panel 'Available variables' do
+      para 'Templates are rendered with liquid. Only the variables below are available; ' \
+           'nothing else about the invoice or account can be referenced.'
+      table_for InvoiceMail.variable_reference do
+        column('Variable') { |r| code "{{ #{r[:name]} }}" }
+        column('Example') { |r| r[:example] }
+      end
+      para 'Money values are exact decimal strings, so a bare {% if amount %} is always true — ' \
+           'compare explicitly. Dates are ISO-8601 strings; format them with {{ invoice.end_date | date: "%Y-%m-%d" }}.'
+      para 'Email clients are not browsers: in the HTML body use inline styles and table layout only, ' \
+           'and keep the plain text body readable on its own — some recipients never see the HTML part.'
+    end
+
+    active_admin_comments
+  end
+
+  form do |f|
+    f.semantic_errors *f.object.errors.attribute_names
+    f.inputs form_title do
+      f.input :subject, hint: 'Liquid. Example: Invoice {{ invoice.reference }}'
+      f.input :text_body, as: :text,
+                          input_html: { rows: 15, style: 'font-family: monospace;' },
+                          hint: 'Plain text alternative part, sent alongside the HTML body.'
+      f.input :html_body, as: :text, input_html: { rows: 25, style: 'font-family: monospace;' }
+    end
+    f.actions
+  end
+end
diff --git a/app/admin/logs/email_logs.rb b/app/admin/logs/email_logs.rb
index c465b1b65..281a49d92 100644
--- a/app/admin/logs/email_logs.rb
+++ b/app/admin/logs/email_logs.rb
@@ -53,6 +53,9 @@ def scoped_collection
       row :mail_from
       row :mail_to
       row :subject
+      row :text_msg do |r|
+        pre(style: 'white-space: pre-wrap; word-break: break-word;') { r.text_msg } if r.text_msg.present?
+      end
       row :msg do |r|
         SandboxedEmailFrame.render(r.msg) if r.msg.present?
       end
diff --git a/app/domain/contact_email_sender.rb b/app/domain/contact_email_sender.rb
index 47f9bccd0..d34b731b9 100644
--- a/app/domain/contact_email_sender.rb
+++ b/app/domain/contact_email_sender.rb
@@ -4,12 +4,19 @@ class ContactEmailSender
   class << self
     # @param contacts [Array]
     # @param subject [String]
-    # @param message [String,nil]
+    # @param message [String,nil] HTML body
+    # @param text_message [String,nil] plain-text alternative; supplying it makes
+    #   the email multipart, omitting it keeps it single-part HTML as before
     # @param attachments [Array e
+    CaptureError.capture(e, extra: { invoice_id: invoice.id, method: method })
+    Rails.logger.error { "InvoiceMail##{method} for invoice #{invoice.id} failed: <#{e.class}>: #{e.message}" }
+    nil
+  end
+end
diff --git a/app/mailers/yeti_mail.rb b/app/mailers/yeti_mail.rb
index aa18c109f..1fa734a78 100644
--- a/app/mailers/yeti_mail.rb
+++ b/app/mailers/yeti_mail.rb
@@ -14,6 +14,13 @@ def email_message(email_log)
       from: email_log.mail_from,
       delivery_method_options: email_log.smtp_connection.delivery_options
     ) do |format|
+      # Only senders that store a plain-text alternative produce a multipart
+      # message; the rest stay single-part HTML exactly as before. Wire order
+      # (text before html, so clients pick the richest part they understand) is
+      # ActionMailer's parts_order, not this block's order.
+      format.text { render plain: email_log.text_msg } if email_log.text_msg.present?
+      # Some body is required even when the message is really just its
+      # attachments, hence the blank placeholder.
       format.html { render html: email_log.msg&.html_safe || '  ' }
     end
   end
diff --git a/app/models/billing/invoice_document.rb b/app/models/billing/invoice_document.rb
index 0cd695daa..f5f2d75c0 100644
--- a/app/models/billing/invoice_document.rb
+++ b/app/models/billing/invoice_document.rb
@@ -31,10 +31,6 @@ def attachments
     ].reject { |a| a.data.blank? }
   end
 
-  def subject
-    invoice.display_name
-  end
-
   delegate :account, to: :invoice
 
   # after_create do
@@ -48,9 +44,15 @@ def send_invoice
     # create attachments
     files = attachments
     files.each(&:save!)
+    # Rendered once for the whole batch — the template has no per-contact
+    # variables, so every recipient gets the same message and the same
+    # attachment row.
+    mail = InvoiceMail.new(self)
     ContactEmailSender.batch_send_emails(
       contacts,
-      subject: subject,
+      subject: mail.subject,
+      message: mail.html_body,
+      text_message: mail.text_body,
       attachments: files
     )
   end
diff --git a/app/models/billing/invoice_email_template.rb b/app/models/billing/invoice_email_template.rb
new file mode 100644
index 000000000..f45a03cb6
--- /dev/null
+++ b/app/models/billing/invoice_email_template.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: billing.invoice_email_templates
+# Database name: primary
+#
+#  id        :integer(2)       default(1), not null, primary key
+#  html_body :text             not null
+#  subject   :string           not null
+#  text_body :text             not null
+#
+
+# The email that carries an approved invoice to the account's contacts, as an
+# editable liquid template. System-wide — a single row, guaranteed by a CHECK
+# constraint and seeded by the migration, so there is nothing to choose per
+# account and #instance never has to cope with more than one.
+#
+# Rendered against the same data the PDF template gets (BillingInvoice::
+# InvoiceData without the per-destination detail), plus a `document` group
+# naming the attachment; see InvoiceMail.
+class Billing::InvoiceEmailTemplate < ApplicationRecord
+  self.table_name = 'billing.invoice_email_templates'
+
+  include WithPaperTrail
+  include LiquidTemplate
+
+  validates :subject, :html_body, :text_body, presence: true
+  validates :subject, length: { maximum: 255 }
+  validates_liquid_syntax :subject, :html_body, :text_body, sample: -> { InvoiceMail.sample_assigns }
+
+  # There is no packaged fallback for the bodies, so the row must survive.
+  before_destroy { throw :abort }
+
+  # The row is created by the migration, so a nil here means a broken install
+  # rather than "not configured yet" — InvoiceMail treats it as such and falls
+  # back rather than raising in the middle of a delivery.
+  def self.instance
+    first
+  end
+
+  def display_name
+    'Invoice email template'
+  end
+
+  def render_subject(assigns)
+    render_liquid(subject, assigns).strip
+  end
+
+  def render_html_body(assigns)
+    render_liquid(html_body, assigns)
+  end
+
+  def render_text_body(assigns)
+    render_liquid(text_body, assigns)
+  end
+end
diff --git a/app/models/billing/notification_template.rb b/app/models/billing/notification_template.rb
index 12e2802df..0af877a83 100644
--- a/app/models/billing/notification_template.rb
+++ b/app/models/billing/notification_template.rb
@@ -25,10 +25,11 @@ module CONST
   end
 
   include WithPaperTrail
+  include LiquidTemplate
 
   validates :event, :subject, :body, presence: true
   validates :event, uniqueness: true, inclusion: { in: CONST::EVENTS }
-  validate :validate_liquid_syntax
+  validates_liquid_syntax :subject, :body, sample: -> { BalanceNotificationMail.sample_assigns }
 
   before_destroy { throw :abort }
 
@@ -37,44 +38,10 @@ def display_name
   end
 
   def render_subject(assigns)
-    render_template(subject, assigns)
+    render_liquid(subject, assigns)
   end
 
   def render_body(assigns)
-    render_template(body, assigns)
-  end
-
-  private
-
-  def render_template(source, assigns)
-    template = parse(source)
-    template.errors.clear # parse memoizes the template; only log this render's errors
-    output = template.render(assigns.deep_stringify_keys, strict_variables: true)
-    if template.errors.any?
-      Rails.logger.warn { "Billing::NotificationTemplate##{id} render: #{template.errors.map(&:message).join('; ')}" }
-    end
-    output
-  end
-
-  def parse(source)
-    (@parsed ||= {})[source] ||= Liquid::Template.parse(source, error_mode: :strict)
-  end
-
-  def validate_liquid_syntax
-    sample = BalanceNotificationMail.sample_assigns.deep_stringify_keys
-
-    { subject: subject, body: body }.each do |attribute, source|
-      next if source.blank?
-
-      begin
-        parse(source).render!(sample, strict_variables: true)
-      rescue Liquid::SyntaxError => e
-        errors.add(attribute, "liquid syntax error: #{e.message}")
-      rescue Liquid::UndefinedVariable => e
-        errors.add(attribute, "unknown variable: #{e.message}")
-      rescue Liquid::Error => e
-        errors.add(attribute, "liquid error: #{e.message}")
-      end
-    end
+    render_liquid(body, assigns)
   end
 end
diff --git a/app/models/concerns/liquid_template.rb b/app/models/concerns/liquid_template.rb
new file mode 100644
index 000000000..224e344e2
--- /dev/null
+++ b/app/models/concerns/liquid_template.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+# Liquid-backed template columns on a model: render them, and reject a template
+# that could not render before it is stored.
+#
+# Rendering is deliberately lenient and validation deliberately strict. A save
+# is a human at a keyboard who can fix a typo immediately, so unknown variables
+# and syntax errors become validation errors there. A render happens inside a
+# background job that is delivering something (an invoice, a balance alert), so
+# it degrades to a blank for the offending variable and logs, rather than
+# wedging the job over a cosmetic problem.
+module LiquidTemplate
+  extend ActiveSupport::Concern
+
+  class_methods do
+    # @param attributes [Array] template columns to check on save
+    # @param sample [Proc] returns the assigns hash a valid template may use;
+    #   anything outside it is an unknown variable, which is the point — the
+    #   documented contract is enforced at save time.
+    def validates_liquid_syntax(*attributes, sample:)
+      validate do
+        assigns = instance_exec(&sample).deep_stringify_keys
+
+        attributes.each do |attribute|
+          source = self[attribute]
+          next if source.blank?
+
+          begin
+            parse_liquid(source).render!(assigns, strict_variables: true)
+          rescue Liquid::SyntaxError => e
+            errors.add(attribute, "liquid syntax error: #{e.message}")
+          rescue Liquid::UndefinedVariable => e
+            errors.add(attribute, "unknown variable: #{e.message}")
+          rescue Liquid::Error => e
+            errors.add(attribute, "liquid error: #{e.message}")
+          end
+        end
+      end
+    end
+  end
+
+  private
+
+  def render_liquid(source, assigns)
+    template = parse_liquid(source)
+    template.errors.clear # parse memoizes the template; only log this render's errors
+    output = template.render(assigns.deep_stringify_keys, strict_variables: true)
+    if template.errors.any?
+      Rails.logger.warn { "#{self.class.name}##{id} render: #{template.errors.map(&:message).join('; ')}" }
+    end
+    output
+  end
+
+  def parse_liquid(source)
+    (@parsed_liquid ||= {})[source] ||= Liquid::Template.parse(source, error_mode: :strict)
+  end
+end
diff --git a/app/models/log/email_log.rb b/app/models/log/email_log.rb
index df3b6a9b4..548f9e34b 100644
--- a/app/models/log/email_log.rb
+++ b/app/models/log/email_log.rb
@@ -12,6 +12,7 @@
 #  msg                :string
 #  sent_at            :timestamptz
 #  subject            :string           not null
+#  text_msg           :text
 #  created_at         :timestamptz
 #  attachment_id      :integer(4)       is an Array
 #  batch_id           :bigint(8)
diff --git a/app/policies/billing/invoice_email_template_policy.rb b/app/policies/billing/invoice_email_template_policy.rb
new file mode 100644
index 000000000..44ecfede6
--- /dev/null
+++ b/app/policies/billing/invoice_email_template_policy.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Billing
+  class InvoiceEmailTemplatePolicy < ::RolePolicy
+    section 'Billing/InvoiceEmailTemplate'
+
+    alias_rule :preview?, to: :read?
+
+    class Scope < ::RolePolicy::Scope
+    end
+  end
+end
diff --git a/app/services/billing_invoice/invoice_data.rb b/app/services/billing_invoice/invoice_data.rb
index b8c46d153..d7476bcb4 100644
--- a/app/services/billing_invoice/invoice_data.rb
+++ b/app/services/billing_invoice/invoice_data.rb
@@ -15,22 +15,32 @@ module BillingInvoice
   # (pongo2 coerces) rather than bare `{% if v %}` on them — "0" is truthy.
   class InvoiceData < ApplicationService
     parameter :invoice, required: true
+    # The per-destination and per-network breakdowns are what make this payload
+    # big — thousands of rows on a busy account. The invoice email quotes only
+    # the totals, so it asks for details: false and skips those four queries.
+    parameter :details, default: true
 
     def call
       {
         account: account_data,
         contractor: contractor_data,
         invoice: invoice_data,
-        originated_destinations: destinations(invoice.originated_destinations.for_invoice.order('dst_prefix')),
-        terminated_destinations: destinations(invoice.terminated_destinations.for_invoice.order('dst_prefix')),
-        originated_networks: networks(invoice.originated_networks.for_invoice.order('country_id, network_id')),
-        terminated_networks: networks(invoice.terminated_networks.for_invoice.order('country_id, network_id')),
+        **(details ? detail_data : {}),
         service_data: services(invoice.service_data.for_invoice)
       }
     end
 
     private
 
+    def detail_data
+      {
+        originated_destinations: destinations(invoice.originated_destinations.for_invoice.order('dst_prefix')),
+        terminated_destinations: destinations(invoice.terminated_destinations.for_invoice.order('dst_prefix')),
+        originated_networks: networks(invoice.originated_networks.for_invoice.order('country_id, network_id')),
+        terminated_networks: networks(invoice.terminated_networks.for_invoice.order('country_id, network_id'))
+      }
+    end
+
     def account_data
       account = invoice.account
       {
diff --git a/db/migrate/20260810120000_create_invoice_email_template.rb b/db/migrate/20260810120000_create_invoice_email_template.rb
new file mode 100644
index 000000000..26f308143
--- /dev/null
+++ b/db/migrate/20260810120000_create_invoice_email_template.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+# The invoice notification email, as an editable liquid template. One row,
+# system-wide: the CHECK makes the singleton a database fact rather than a
+# convention, so there is no id to choose and nothing to select per account.
+#
+# Both bodies are NOT NULL and seeded here, because there is no packaged
+# fallback — a blank row would mean invoices go out with an empty message.
+class CreateInvoiceEmailTemplate < ActiveRecord::Migration[7.2]
+  def up
+    execute %q{
+      CREATE TABLE billing.invoice_email_templates (
+        id smallint PRIMARY KEY DEFAULT 1,
+        subject character varying NOT NULL,
+        html_body text NOT NULL,
+        text_body text NOT NULL,
+        CONSTRAINT invoice_email_templates_singleton CHECK (id = 1)
+      );
+    }
+
+    execute <<~'SQL'
+      INSERT INTO billing.invoice_email_templates (id, subject, html_body, text_body) VALUES (1, $tpl$Invoice {{ invoice.reference }}$tpl$, $tpl$
+      
+        
+          
+        
+      
+ + + + + + + + + + +
Invoice {{ invoice.reference }}
+

Dear {{ account.name }},

+

Please find attached invoice {{ invoice.reference }} covering the period from {{ invoice.start_date | date: "%Y-%m-%d" }} to {{ invoice.end_date | date: "%Y-%m-%d" }}.

+ + + + + + + + + + + + + + + + + + + + + +
Invoice reference{{ invoice.reference }}
Period{{ invoice.start_date | date: "%Y-%m-%d" }} – {{ invoice.end_date | date: "%Y-%m-%d" }}
Account{{ account.name }} (ID {{ account.id }})
Amount{{ invoice.amount_total }} {{ account.currency }}
Attachment{{ document.filename }}
+
This is an automated notification. Please do not reply to this message.
+
+ $tpl$, $tpl$Invoice {{ invoice.reference }} + + Dear {{ account.name }}, + + Please find attached invoice {{ invoice.reference }} covering the period + from {{ invoice.start_date | date: "%Y-%m-%d" }} to {{ invoice.end_date | date: "%Y-%m-%d" }}. + + Invoice reference: {{ invoice.reference }} + Period: {{ invoice.start_date | date: "%Y-%m-%d" }} - {{ invoice.end_date | date: "%Y-%m-%d" }} + Account: {{ account.name }} (ID {{ account.id }}) + Amount: {{ invoice.amount_total }} {{ account.currency }} + Attachment: {{ document.filename }} + + This is an automated notification. Please do not reply to this message. + $tpl$); + SQL + end + + def down + execute %q{ + DROP TABLE billing.invoice_email_templates; + } + end +end diff --git a/db/migrate/20260810120001_add_text_msg_to_email_logs.rb b/db/migrate/20260810120001_add_text_msg_to_email_logs.rb new file mode 100644 index 000000000..0088e65b8 --- /dev/null +++ b/db/migrate/20260810120001_add_text_msg_to_email_logs.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Plain-text alternative part of an outgoing email. Nullable: only senders that +# supply one produce a multipart message, so existing senders (balance +# notifications, report emails) keep sending single-part HTML unchanged. +class AddTextMsgToEmailLogs < ActiveRecord::Migration[7.2] + def up + execute %q{ + ALTER TABLE notifications.email_logs ADD COLUMN text_msg text; + } + end + + def down + execute %q{ + ALTER TABLE notifications.email_logs DROP COLUMN text_msg; + } + end +end diff --git a/db/seeds/main/billing.sql b/db/seeds/main/billing.sql index 44cac3fe9..e478d7daf 100644 --- a/db/seeds/main/billing.sql +++ b/db/seeds/main/billing.sql @@ -252,6 +252,72 @@ SELECT pg_catalog.setval('billing.notification_templates_id_seq', 4, true); -- END notification_templates (generated) +-- BEGIN invoice_email_templates (generated) +-- +-- Data for Name: invoice_email_templates; Type: TABLE DATA; Schema: billing; Owner: yeti +-- + +INSERT INTO billing.invoice_email_templates (id, subject, html_body, text_body) VALUES (1, $tpl$Invoice {{ invoice.reference }}$tpl$, $tpl$ + + + + +
+ + + + + + + + + + +
Invoice {{ invoice.reference }}
+

Dear {{ account.name }},

+

Please find attached invoice {{ invoice.reference }} covering the period from {{ invoice.start_date | date: "%Y-%m-%d" }} to {{ invoice.end_date | date: "%Y-%m-%d" }}.

+ + + + + + + + + + + + + + + + + + + + + +
Invoice reference{{ invoice.reference }}
Period{{ invoice.start_date | date: "%Y-%m-%d" }} – {{ invoice.end_date | date: "%Y-%m-%d" }}
Account{{ account.name }} (ID {{ account.id }})
Amount{{ invoice.amount_total }} {{ account.currency }}
Attachment{{ document.filename }}
+
This is an automated notification. Please do not reply to this message.
+
+$tpl$, $tpl$Invoice {{ invoice.reference }} + +Dear {{ account.name }}, + +Please find attached invoice {{ invoice.reference }} covering the period +from {{ invoice.start_date | date: "%Y-%m-%d" }} to {{ invoice.end_date | date: "%Y-%m-%d" }}. + + Invoice reference: {{ invoice.reference }} + Period: {{ invoice.start_date | date: "%Y-%m-%d" }} - {{ invoice.end_date | date: "%Y-%m-%d" }} + Account: {{ account.name }} (ID {{ account.id }}) + Amount: {{ invoice.amount_total }} {{ account.currency }} + Attachment: {{ document.filename }} + +This is an automated notification. Please do not reply to this message. +$tpl$); + +-- END invoice_email_templates (generated) + -- Completed on 2017-08-20 19:13:57 EEST -- diff --git a/db/structure.sql b/db/structure.sql index 5d1265d8a..cdcbf84d1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11328,6 +11328,19 @@ CREATE SEQUENCE billing.currencies_id_seq ALTER SEQUENCE billing.currencies_id_seq OWNED BY billing.currencies.id; +-- +-- Name: invoice_email_templates; Type: TABLE; Schema: billing; Owner: - +-- + +CREATE TABLE billing.invoice_email_templates ( + id smallint DEFAULT 1 NOT NULL, + subject character varying NOT NULL, + html_body text NOT NULL, + text_body text NOT NULL, + CONSTRAINT invoice_email_templates_singleton CHECK ((id = 1)) +); + + -- -- Name: invoice_templates; Type: TABLE; Schema: billing; Owner: - -- @@ -14989,7 +15002,8 @@ CREATE TABLE notifications.email_logs ( subject character varying NOT NULL, msg character varying, error character varying, - attachment_id integer[] + attachment_id integer[], + text_msg text ); @@ -17070,6 +17084,14 @@ ALTER TABLE ONLY billing.currencies ADD CONSTRAINT currencies_pkey PRIMARY KEY (id); +-- +-- Name: invoice_email_templates invoice_email_templates_pkey; Type: CONSTRAINT; Schema: billing; Owner: - +-- + +ALTER TABLE ONLY billing.invoice_email_templates + ADD CONSTRAINT invoice_email_templates_pkey PRIMARY KEY (id); + + -- -- Name: invoice_templates invoices_templates_name_key; Type: CONSTRAINT; Schema: billing; Owner: - -- @@ -20795,6 +20817,8 @@ ALTER TABLE ONLY sys.sensors SET search_path TO gui, public, switch, billing, class4, runtime_stats, sys, logs, data_import; INSERT INTO "public"."schema_migrations" (version) VALUES +('20260810120001'), +('20260810120000'), ('20260809120000'), ('20260729120000'), ('20260719120000'), diff --git a/spec/domain/contact_email_sender_spec.rb b/spec/domain/contact_email_sender_spec.rb index cd4ce5f68..766907654 100644 --- a/spec/domain/contact_email_sender_spec.rb +++ b/spec/domain/contact_email_sender_spec.rb @@ -14,6 +14,7 @@ mail_from: global_smtp_connection.from_address, subject: service_params[:subject], msg: service_params[:message], + text_msg: nil, attachment_id: nil } end @@ -47,6 +48,17 @@ include_examples :creates_email_log + context 'when a plain-text alternative is supplied' do + let(:service_params) do + { subject: 'Hello test', message: 'some text', text_message: 'some text' } + end + let(:expected_email_log_attrs) do + super().merge text_msg: 'some text' + end + + include_examples :creates_email_log + end + context 'when contact.contractor has smtp_connection' do let!(:smtp_connection) do FactoryBot.create(:smtp_connection, from_address: 'some@example.com') @@ -133,12 +145,14 @@ attachments: FactoryBot.create_list(:notification_attachment, 2) } end + # every keyword is forwarded, including the ones the caller left out + let(:forwarded_params) { { text_message: nil }.merge(service_params) } it 'send emails to all contacts' do contacts.each do |contact| sender_stub = instance_double(described_class) expect(described_class).to receive(:new).with(contact).once.and_return(sender_stub) - expect(sender_stub).to receive(:send_email).with(service_params).once + expect(sender_stub).to receive(:send_email).with(forwarded_params).once end subject end @@ -153,7 +167,7 @@ contacts.uniq.each do |contact| sender_stub = instance_double(described_class) expect(described_class).to receive(:new).with(contact).once.and_return(sender_stub) - expect(sender_stub).to receive(:send_email).with(service_params).once + expect(sender_stub).to receive(:send_email).with(forwarded_params).once end subject end diff --git a/spec/domain/invoice_mail_spec.rb b/spec/domain/invoice_mail_spec.rb new file mode 100644 index 000000000..a27a47dae --- /dev/null +++ b/spec/domain/invoice_mail_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +RSpec.describe InvoiceMail do + subject(:mail) { described_class.new(invoice_document) } + + let!(:contractor) { FactoryBot.create(:vendor) } + let!(:account) { FactoryBot.create(:account, contractor: contractor) } + let!(:invoice) do + FactoryBot.create(:invoice, + account: account, + type_id: Billing::InvoiceType::MANUAL, + state_id: Billing::InvoiceState::NEW, + start_date: Time.zone.parse('2020-01-01 00:00:00'), + end_date: Time.zone.parse('2020-02-01 00:00:00')) + end + let!(:invoice_document) do + FactoryBot.create(:invoice_document, :filled, invoice: invoice, filename: 'invoice-77') + end + let(:template) { Billing::InvoiceEmailTemplate.instance } + + describe '#assigns' do + it 'exposes the same vocabulary as the PDF payload' do + expect(mail.assigns).to include(:account, :contractor, :invoice, :service_data, :document) + end + + it 'omits the per-destination and per-network breakdowns an email never quotes' do + expect(mail.assigns).not_to include(:originated_destinations, :terminated_destinations, + :originated_networks, :terminated_networks) + end + + it 'names the attachment carried by this very message' do + expect(mail.assigns[:document]).to eq(filename: 'invoice-77.pdf') + end + + it 'covers every variable the documented contract promises' do + # A key in sample_assigns that #assigns omits would validate at save time + # and then render blank in production — the one drift that matters. + expect(mail.assigns.keys).to include(*described_class.sample_assigns.keys) + end + end + + describe '#subject' do + it 'renders the stored template' do + template.update!(subject: 'Invoice {{ invoice.reference }}') + expect(mail.subject).to eq("Invoice #{invoice.reference}") + end + + it 'falls back to the invoice name when the template renders to blank' do + template.update_column(:subject, ' ') + expect(mail.subject).to eq(invoice.display_name) + end + + context 'when the template row is missing' do + before { allow(Billing::InvoiceEmailTemplate).to receive(:instance).and_return(nil) } + + it 'falls back to the invoice name rather than raising mid-delivery' do + expect(mail.subject).to eq(invoice.display_name) + end + end + + context 'when rendering blows up' do + before do + allow(Billing::InvoiceEmailTemplate).to receive(:instance).and_return(template) + allow(template).to receive(:render_subject).and_raise(StandardError, 'boom') + end + + it 'falls back instead of losing the email' do + expect(mail.subject).to eq(invoice.display_name) + end + + it 'captures the error so the broken template gets fixed' do + expect(CaptureError).to receive(:capture) + mail.subject + end + end + end + + describe '#html_body and #text_body' do + before do + template.update!(html_body: '{{ invoice.reference }}', text_body: 'ref {{ invoice.reference }}') + end + + it 'renders both parts' do + expect(mail.html_body).to eq("#{invoice.reference}") + expect(mail.text_body).to eq("ref #{invoice.reference}") + end + + context 'when the template row is missing' do + before { allow(Billing::InvoiceEmailTemplate).to receive(:instance).and_return(nil) } + + it 'returns nil, which keeps the message single-part as it was before templates' do + expect(mail.html_body).to be_nil + expect(mail.text_body).to be_nil + end + end + end + + describe '.sample_assigns' do + it 'renders the seeded templates, so the admin preview always works' do + expect { template.render_subject(described_class.sample_assigns) }.not_to raise_error + expect { template.render_html_body(described_class.sample_assigns) }.not_to raise_error + expect { template.render_text_body(described_class.sample_assigns) }.not_to raise_error + end + end + + describe '.variable_reference' do + it 'documents every scalar the contract exposes' do + names = described_class.variable_reference.map { |r| r[:name] } + expect(names).to include('invoice.reference', 'invoice.originated.calls_count', + 'account.name', 'contractor.name', 'document.filename') + end + end +end diff --git a/spec/factories/log/email_logs.rb b/spec/factories/log/email_logs.rb index df9fcac89..7581c8197 100644 --- a/spec/factories/log/email_logs.rb +++ b/spec/factories/log/email_logs.rb @@ -12,6 +12,7 @@ # msg :string # sent_at :timestamptz # subject :string not null +# text_msg :text # created_at :timestamptz # attachment_id :integer(4) is an Array # batch_id :bigint(8) diff --git a/spec/features/billing/invoice_email_templates/manage_template_spec.rb b/spec/features/billing/invoice_email_templates/manage_template_spec.rb new file mode 100644 index 000000000..1b3ab0fe8 --- /dev/null +++ b/spec/features/billing/invoice_email_templates/manage_template_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +RSpec.describe 'Manage the invoice email template' do + include_context :login_as_admin + + let(:template) { Billing::InvoiceEmailTemplate.instance } + + describe 'index' do + before { visit invoice_email_templates_path } + + it 'goes straight to the single row, since there is nothing to list' do + expect(page).to have_current_path(invoice_email_template_path(1)) + end + + it 'offers no way to create one' do + expect(page).not_to have_link('New Invoice email template') + end + end + + describe 'show' do + before { visit invoice_email_template_path(template) } + + it 'documents the variables available to the template' do + expect(page).to have_content('invoice.reference') + expect(page).to have_content('document.filename') + end + + it 'shows both parts, because an email that reads badly in plain text is half broken' do + expect(page).to have_content('Plain text body') + expect(page).to have_content('HTML body') + end + + it 'offers no delete action' do + expect(page).not_to have_link('Delete') + end + end + + describe 'edit' do + before { visit edit_invoice_email_template_path(template) } + + it 'saves a valid template' do + fill_in 'billing_invoice_email_template[text_body]', with: 'Invoice {{ invoice.reference }}' + click_button 'Update Invoice email template' + + expect(template.reload.text_body).to eq('Invoice {{ invoice.reference }}') + end + + it 'rejects a template referencing an unavailable variable' do + original = template.html_body + fill_in 'billing_invoice_email_template[html_body]', with: '{{ invoice.secret_column }}' + click_button 'Update Invoice email template' + + expect(page).to have_content('unknown variable') + expect(template.reload.html_body).to eq(original) + end + end + + describe 'preview' do + it 'renders both parts against sample data, the HTML inside a sandboxed frame' do + visit preview_invoice_email_template_path(template) + + expect(page.first('iframe')['sandbox']).to eq('') + expect(page.body).to include('Plain text part') + expect(page.body).to include('HTML part') + expect(page.body).to include('invoice-1042') + end + + context 'when the stored template contains script' do + before do + template.update_column(:html_body, '

hi

') + visit preview_invoice_email_template_path(template) + end + + it 'escapes the body into srcdoc rather than the page DOM' do + expect(page.first('iframe')['sandbox']).to eq('') + expect(page.body).to include('srcdoc=') + expect(page.body).not_to include('') + end + end + + context 'when the stored template cannot render' do + before do + template.update_column(:text_body, '{{ invoice.nope }}') + visit preview_invoice_email_template_path(template) + end + + # An unknown variable degrades to a blank at render time rather than + # raising, so the preview still renders — that is the point of the + # lenient render path. + it 'still shows the preview' do + expect(page.body).to include('Plain text part') + end + end + end +end diff --git a/spec/mailers/yeti_mail_spec.rb b/spec/mailers/yeti_mail_spec.rb index 805eb4704..f763d367e 100644 --- a/spec/mailers/yeti_mail_spec.rb +++ b/spec/mailers/yeti_mail_spec.rb @@ -16,11 +16,10 @@ ) end let!(:log) do - ContactEmailSender.new(contact).send_email( - subject: 'test', - message: '

Hello

', - attachments: [attachment] - ) + ContactEmailSender.new(contact).send_email(**send_params) + end + let(:send_params) do + { subject: 'test', message: '

Hello

', attachments: [attachment] } end let(:attachment) do FactoryBot.create(:notification_attachment, filename: 'test.txt', data: 'some data') @@ -35,5 +34,52 @@ expect(subject.from).to eq([log.mail_from]) expect(subject.body.encoded).to include(log.msg) end + + context 'without a plain-text alternative' do + let(:send_params) { { subject: 'test', message: '

Hello

' } } + + # Balance notifications and report emails supply no text part, so this is + # the shape they must keep producing. + it 'stays a single-part HTML message' do + expect(subject).not_to be_multipart + expect(subject.content_type).to start_with('text/html') + end + end + + context 'with a plain-text alternative' do + let(:send_params) do + { subject: 'test', message: '

Hello

', text_message: 'Hello' } + end + + it 'sends multipart/alternative with the plain part first' do + expect(subject).to be_multipart + expect(subject.content_type).to start_with('multipart/alternative') + # Clients render the LAST part they understand, so text before html is + # what makes an HTML-capable client show the HTML. + expect(subject.parts.map { |p| p.content_type.split(';').first }) + .to eq(['text/plain', 'text/html']) + end + + it 'carries both bodies' do + expect(subject.text_part.body.to_s).to include('Hello') + expect(subject.html_part.body.to_s).to include('

Hello

') + end + end + + context 'with a plain-text alternative and attachments' do + let(:send_params) do + { subject: 'test', message: '

Hello

', text_message: 'Hello', attachments: [attachment] } + end + + it 'nests the alternative inside multipart/mixed alongside the attachment' do + expect(subject.content_type).to start_with('multipart/mixed') + expect(subject.attachments.map(&:filename)).to eq(['test.txt']) + + alternative = subject.parts.find { |p| p.content_type.start_with?('multipart/alternative') } + expect(alternative).to be_present + expect(alternative.parts.map { |p| p.content_type.split(';').first }) + .to eq(['text/plain', 'text/html']) + end + end end end diff --git a/spec/models/billing/invoice_email_template_spec.rb b/spec/models/billing/invoice_email_template_spec.rb new file mode 100644 index 000000000..c9a3539f2 --- /dev/null +++ b/spec/models/billing/invoice_email_template_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: billing.invoice_email_templates +# Database name: primary +# +# id :integer(2) default(1), not null, primary key +# html_body :text not null +# subject :string not null +# text_body :text not null +# +RSpec.describe Billing::InvoiceEmailTemplate do + let(:template) { described_class.instance } + let(:assigns) { InvoiceMail.sample_assigns } + + describe '.instance' do + it 'returns the seeded row' do + expect(template).to be_present + expect(template.id).to eq(1) + end + end + + describe 'the singleton constraint' do + it 'refuses a second row' do + expect { described_class.connection.execute(<<~SQL) }.to raise_error(ActiveRecord::StatementInvalid) + INSERT INTO billing.invoice_email_templates (id, subject, html_body, text_body) + VALUES (2, 's', 'h', 't') + SQL + end + end + + describe '#destroy' do + it 'is refused, because there is no packaged fallback for the bodies' do + expect(template.destroy).to be false + expect(described_class.exists?(template.id)).to be true + end + end + + describe 'validation' do + it 'rejects invalid liquid syntax' do + template.html_body = '{% if %}broken' + expect(template).not_to be_valid + expect(template.errors[:html_body].first).to match(/liquid syntax error/) + end + + it 'rejects references to variables that will never be supplied' do + template.text_body = '{{ invoice.secret_column }}' + expect(template).not_to be_valid + expect(template.errors[:text_body].first).to match(/unknown variable/) + end + + it 'validates the subject too' do + template.subject = '{{ invoice.nope }}' + expect(template).not_to be_valid + expect(template.errors[:subject].first).to match(/unknown variable/) + end + + it 'accepts a template using only the documented contract' do + template.subject = 'Invoice {{ invoice.reference }}' + template.html_body = '

{{ account.name }} owes {{ invoice.amount_total }} {{ account.currency }}

' + template.text_body = '{{ document.filename }}' + expect(template).to be_valid + end + + it 'requires both bodies, since every invoice email carries both parts' do + template.html_body = '' + template.text_body = '' + expect(template).not_to be_valid + expect(template.errors[:html_body]).to be_present + expect(template.errors[:text_body]).to be_present + end + end + + describe 'seeded content' do + it 'ships usable templates, since there is no packaged fallback' do + expect(template.subject).to be_present + expect(template.html_body).to be_present + expect(template.text_body).to be_present + expect(template).to be_valid + end + end + + describe '#render_subject' do + it 'renders the stored template' do + template.update!(subject: 'Invoice {{ invoice.reference }}') + expect(template.render_subject(assigns)).to eq('Invoice invoice-1042') + end + + it 'strips surrounding whitespace, because EmailLog#subject is NOT NULL' do + template.update!(subject: " {{ invoice.reference }}\n") + expect(template.render_subject(assigns)).to eq('invoice-1042') + end + end + + describe '#render_text_body' do + it 'renders the stored template' do + template.update!(text_body: 'Total {{ invoice.amount_total }}') + expect(template.render_text_body(assigns)).to eq('Total 1234.56') + end + + context 'when a row bypasses validation with an unknown variable' do + before { template.update_column(:text_body, 'X {{ invoice.nope }} Y') } + + it 'degrades to a blank instead of raising, so the delivery is not lost' do + expect { @rendered = template.render_text_body(assigns) }.not_to raise_error + expect(@rendered).to eq('X Y') + end + + it 'logs the undefined variable rather than failing silently' do + expect(Rails.logger).to receive(:warn) + template.render_text_body(assigns) + end + end + end + + describe '#render_html_body' do + it 'renders the stored template' do + template.update!(html_body: '{{ account.name }}') + expect(template.render_html_body(assigns)).to eq('Sample account') + end + end +end diff --git a/spec/services/billing_invoice/approve_spec.rb b/spec/services/billing_invoice/approve_spec.rb index 5670157ef..3d2c21621 100644 --- a/spec/services/billing_invoice/approve_spec.rb +++ b/spec/services/billing_invoice/approve_spec.rb @@ -23,6 +23,21 @@ it 'enqueues email worker' do expect { subject }.to have_enqueued_job(Worker::SendEmailLogJob) end + + it 'logs an email rendered from the invoice email template' do + Billing::InvoiceEmailTemplate.instance.update!( + subject: 'Invoice {{ invoice.reference }}', + html_body: '{{ invoice.reference }}', + text_body: 'ref {{ invoice.reference }}' + ) + + expect { subject }.to change { Log::EmailLog.count }.by(1) + expect(Log::EmailLog.last!).to have_attributes( + subject: "Invoice #{invoice.reference}", + msg: "#{invoice.reference}", + text_msg: "ref #{invoice.reference}" + ) + end end context 'when invoice already approved' do diff --git a/spec/services/billing_invoice/invoice_data_spec.rb b/spec/services/billing_invoice/invoice_data_spec.rb index 3ad5431e0..eb9a404f7 100644 --- a/spec/services/billing_invoice/invoice_data_spec.rb +++ b/spec/services/billing_invoice/invoice_data_spec.rb @@ -49,4 +49,25 @@ it 'formats timestamps as ISO-8601 strings' do expect(payload[:invoice][:start_date]).to eq(invoice.start_date.iso8601) end + + context 'with details: false' do + subject(:payload) { described_class.call(invoice: invoice, details: false) } + + it 'drops the per-destination and per-network breakdowns' do + expect(payload).not_to include(:originated_destinations, :terminated_destinations, + :originated_networks, :terminated_networks) + end + + it 'keeps everything else exactly as the full payload has it' do + full = described_class.call(invoice: invoice) + expect(payload).to eq(full.except(:originated_destinations, :terminated_destinations, + :originated_networks, :terminated_networks)) + end + + it 'does not query the detail collections' do + expect(invoice).not_to receive(:originated_destinations) + expect(invoice).not_to receive(:terminated_networks) + payload + end + end end From 1c511730a9ac9707bdb63a1be0be838b9993559c Mon Sep 17 00:00:00 2001 From: sdi Date: Tue, 11 Aug 2026 11:27:06 +0300 Subject: [PATCH 2/2] Add configurable invoice email templates --- app/admin/billing/invoice_email_templates.rb | 2 +- app/admin/billing/notification_templates.rb | 2 +- app/mailers/yeti_mail.rb | 15 +++++++++--- spec/domain/contact_email_sender_spec.rb | 4 ++-- .../manage_template_spec.rb | 2 +- spec/mailers/yeti_mail_spec.rb | 23 +++++++++++++++++++ 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/admin/billing/invoice_email_templates.rb b/app/admin/billing/invoice_email_templates.rb index 6e17d10ee..ce89776c9 100644 --- a/app/admin/billing/invoice_email_templates.rb +++ b/app/admin/billing/invoice_email_templates.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true ActiveAdmin.register Billing::InvoiceEmailTemplate, as: 'InvoiceEmailTemplate' do - menu parent: %w[Billing Settings], label: 'Invoice email template', priority: 92 + menu parent: %w[Billing Settings], label: 'Invoice email templates', priority: 91 config.batch_actions = false config.filters = false diff --git a/app/admin/billing/notification_templates.rb b/app/admin/billing/notification_templates.rb index b1f85cdb6..294a5fbe8 100644 --- a/app/admin/billing/notification_templates.rb +++ b/app/admin/billing/notification_templates.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true ActiveAdmin.register Billing::NotificationTemplate do - menu parent: %w[Billing Settings], label: 'Notification templates', priority: 91 + menu parent: %w[Billing Settings], label: 'Notification templates', priority: 92 config.batch_actions = false actions :index, :show, :edit, :update diff --git a/app/mailers/yeti_mail.rb b/app/mailers/yeti_mail.rb index 1fa734a78..dc7a5f00c 100644 --- a/app/mailers/yeti_mail.rb +++ b/app/mailers/yeti_mail.rb @@ -19,9 +19,18 @@ def email_message(email_log) # (text before html, so clients pick the richest part they understand) is # ActionMailer's parts_order, not this block's order. format.text { render plain: email_log.text_msg } if email_log.text_msg.present? - # Some body is required even when the message is really just its - # attachments, hence the blank placeholder. - format.html { render html: email_log.msg&.html_safe || ' ' } + + if email_log.msg.present? + format.html { render html: email_log.msg.html_safe } + elsif email_log.text_msg.blank? + # No body at all — the message is really just its attachments, but Mail + # still needs something to send, hence the historical placeholder. + format.html { render html: ' ' } + end + # An empty msg alongside a real text_msg deliberately adds NO html part: + # a client picks the last part it understands, so a blank one would win + # and hide the only body there is. This is the degraded-template case — + # InvoiceMail renders the two bodies independently, so one can fail alone. end end end diff --git a/spec/domain/contact_email_sender_spec.rb b/spec/domain/contact_email_sender_spec.rb index 766907654..d7c0fb925 100644 --- a/spec/domain/contact_email_sender_spec.rb +++ b/spec/domain/contact_email_sender_spec.rb @@ -152,7 +152,7 @@ contacts.each do |contact| sender_stub = instance_double(described_class) expect(described_class).to receive(:new).with(contact).once.and_return(sender_stub) - expect(sender_stub).to receive(:send_email).with(forwarded_params).once + expect(sender_stub).to receive(:send_email).with(**forwarded_params).once end subject end @@ -167,7 +167,7 @@ contacts.uniq.each do |contact| sender_stub = instance_double(described_class) expect(described_class).to receive(:new).with(contact).once.and_return(sender_stub) - expect(sender_stub).to receive(:send_email).with(forwarded_params).once + expect(sender_stub).to receive(:send_email).with(**forwarded_params).once end subject end diff --git a/spec/features/billing/invoice_email_templates/manage_template_spec.rb b/spec/features/billing/invoice_email_templates/manage_template_spec.rb index 1b3ab0fe8..8d5b57af0 100644 --- a/spec/features/billing/invoice_email_templates/manage_template_spec.rb +++ b/spec/features/billing/invoice_email_templates/manage_template_spec.rb @@ -13,7 +13,7 @@ end it 'offers no way to create one' do - expect(page).not_to have_link('New Invoice email template') + expect(page).not_to have_selector("a[href$='/invoice_email_templates/new']") end end diff --git a/spec/mailers/yeti_mail_spec.rb b/spec/mailers/yeti_mail_spec.rb index f763d367e..5135388e7 100644 --- a/spec/mailers/yeti_mail_spec.rb +++ b/spec/mailers/yeti_mail_spec.rb @@ -66,6 +66,29 @@ end end + context 'with a plain-text alternative but no HTML body' do + let(:send_params) do + { subject: 'test', message: nil, text_message: 'Hello' } + end + + # Reachable when a template renders one body and fails the other: a blank + # html part would be the LAST part a client understands, so it would win + # and the recipient would see an empty email. + it 'sends a single-part plain-text message rather than an empty HTML one' do + expect(subject).not_to be_multipart + expect(subject.content_type).to start_with('text/plain') + expect(subject.body.to_s).to include('Hello') + end + end + + context 'with neither body' do + let(:send_params) { { subject: 'test', attachments: [attachment] } } + + it 'still sends, because the message is really its attachments' do + expect(subject.attachments.map(&:filename)).to eq(['test.txt']) + end + end + context 'with a plain-text alternative and attachments' do let(:send_params) do { subject: 'test', message: '

Hello

', text_message: 'Hello', attachments: [attachment] }