Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions app/admin/billing/invoice_email_templates.rb
Original file line number Diff line number Diff line change
@@ -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
<div style="font-family:Arial,Helvetica,sans-serif;padding:16px;">
<h3>Plain text part</h3>
<pre style="border:1px solid #ddd;background:#fff;padding:12px;white-space:pre-wrap;">#{text_part}</pre>
<h3>HTML part</h3>
#{html_part}
</div>
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 <pre><code class="language-django">; 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
2 changes: 1 addition & 1 deletion app/admin/billing/notification_templates.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/admin/logs/email_logs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions app/domain/contact_email_sender.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ class ContactEmailSender
class << self
# @param contacts [Array<Billing::Contact>]
# @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<Notification::Attachment,nil]
def batch_send_emails(contacts, subject:, message: nil, attachments: nil)
def batch_send_emails(contacts, subject:, message: nil, text_message: nil, attachments: nil)
ApplicationRecord.transaction do
contacts.uniq.map do |contact|
new(contact).send_email(subject: subject, message: message, attachments: attachments)
new(contact).send_email(
subject: subject,
message: message,
text_message: text_message,
attachments: attachments
)
end
end
end
Expand All @@ -21,15 +28,17 @@ def initialize(contact)
end

# @param subject [String]
# @param message [String,nil]
# @param message [String,nil] HTML body
# @param text_message [String,nil] plain-text alternative
# @param attachments [Array<Notification::Attachment,nil]
def send_email(subject:, message: nil, attachments: nil)
def send_email(subject:, message: nil, text_message: nil, attachments: nil)
return if contact.smtp_connection.nil?

ApplicationRecord.transaction do
email_log = create_email_log(
subject: subject,
message: message,
text_message: text_message,
attachments: attachments
)
Worker::SendEmailLogJob.perform_later(email_log.id)
Expand All @@ -41,14 +50,15 @@ def send_email(subject:, message: nil, attachments: nil)

attr_reader :contact

def create_email_log(subject:, message:, attachments:)
def create_email_log(subject:, message:, text_message:, attachments:)
Log::EmailLog.create!(
contact: contact,
smtp_connection: contact.smtp_connection,
mail_to: contact.email,
mail_from: contact.smtp_connection.from_address,
subject: subject,
msg: message.presence,
text_msg: text_message.presence,
attachment_id: attachments.presence&.map(&:id).presence
)
end
Expand Down
131 changes: 131 additions & 0 deletions app/domain/invoice_mail.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# frozen_string_literal: true

# The email that delivers an approved invoice: renders the system-wide
# Billing::InvoiceEmailTemplate against the invoice's data.
#
# Rendered once per invoice and shared by every recipient — the assigns
# deliberately say nothing about the individual contact, so one render serves
# the whole batch (as the single attachment row already does).
#
# Nothing here may raise. It runs between "invoice approved" and "email
# queued", so a template problem must cost the message its formatting, not its
# delivery: every failure falls back to the plain subject the code used before
# templates existed, and is captured for someone to fix.
class InvoiceMail
class << self
# The variable contract: what a template may reference, and what the admin
# preview and save-time validation render against. Keep in step with
# #assigns — a key here that #assigns omits will validate and then render
# blank in production.
def sample_assigns
{
account: {
id: 123, name: 'Sample account', currency_id: 978, currency: 'EUR',
balance: '1500.00', min_balance: '0.0', max_balance: '100000.0',
invoice_period: 'Monthly'
},
contractor: { name: 'Sample contractor', address: '1 Example street', phones: '+11234567890' },
invoice: sample_invoice,
service_data: [{ service: 'Sample service', transactions_count: 3, amount: '30.00' }],
document: { filename: 'invoice-1042.pdf' }
}
end

# Flat name/example pairs for the "Available variables" admin panel.
# Collections are listed by their group name only — a template iterates
# them with {% for %}, so per-row keys are documented in the panel text.
def variable_reference
sample_assigns.flat_map do |group, values|
next [{ name: group.to_s, example: "collection of #{values.first&.keys&.join(', ')}" }] if values.is_a?(Array)

flatten_group(group, values)
end
end

private

def sample_invoice
{
id: 1042, reference: 'invoice-1042',
created_at: '2026-08-01T00:00:00+00:00',
start_date: '2026-07-01T00:00:00+00:00',
end_date: '2026-08-01T00:00:00+00:00',
amount_total: '1234.56', amount_spent: '1500.00', amount_earned: '265.44',
originated: sample_leg, terminated: sample_leg,
services: { amount_spent: '30.00', amount_earned: '0.0', transactions_count: 3 }
}
end

def sample_leg
{
amount_spent: '750.00', amount_earned: '132.72',
calls_count: 1000, successful_calls_count: 900,
calls_duration: 54_000,
first_call_at: '2026-07-01T00:01:00+00:00',
last_call_at: '2026-07-31T23:59:00+00:00'
}
end

def flatten_group(group, values)
values.flat_map do |key, value|
if value.is_a?(Hash)
value.map { |sub, sub_value| { name: "#{group}.#{key}.#{sub}", example: sub_value.to_s } }
else
[{ name: "#{group}.#{key}", example: value.to_s }]
end
end
end
end

# @param invoice_document [Billing::InvoiceDocument]
def initialize(invoice_document)
@invoice_document = invoice_document
end

def template
@template ||= Billing::InvoiceEmailTemplate.instance
end

# @return [String] never blank — Log::EmailLog#subject is NOT NULL, and a
# template rendering to whitespace must not fail the insert.
def subject
rendered = render(:render_subject)
rendered.presence || invoice.display_name
end

# @return [String,nil] nil renders the same placeholder body as before
# templates existed
def html_body
render(:render_html_body)
end

# @return [String,nil] nil keeps the message single-part, as it was before
def text_body
render(:render_text_body)
end

# The same vocabulary the PDF template sees, minus the per-destination and
# per-network breakdowns (an email quotes totals), plus the name of the file
# attached to this very message.
def assigns
@assigns ||= BillingInvoice::InvoiceData
.call(invoice: invoice, details: false)
.merge(document: { filename: "#{invoice_document.filename}.pdf" })
end

private

attr_reader :invoice_document

delegate :invoice, to: :invoice_document

def render(method)
return if template.nil?

template.public_send(method, assigns)
rescue StandardError => 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
18 changes: 17 additions & 1 deletion app/mailers/yeti_mail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 7 additions & 5 deletions app/models/billing/invoice_document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading