diff --git a/app/admin/billing/invoice_email_templates.rb b/app/admin/billing/invoice_email_templates.rb new file mode 100644 index 000000000..ce89776c9 --- /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 templates', priority: 91 + 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 +
#{text_part}
+ ; 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/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/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..dc7a5f00c 100644
--- a/app/mailers/yeti_mail.rb
+++ b/app/mailers/yeti_mail.rb
@@ -14,7 +14,23 @@ def email_message(email_log)
from: email_log.mail_from,
delivery_method_options: email_log.smtp_connection.delivery_options
) do |format|
- format.html { render html: email_log.msg&.html_safe || ' ' }
+ # 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?
+
+ 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/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..d7c0fb925 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..8d5b57af0
--- /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_selector("a[href$='/invoice_email_templates/new']")
+ 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..5135388e7 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,75 @@
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 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] }
+ 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