Building a Production Email System with Dynamic PDFs in Phoenix
Building a Production Email System with Dynamic PDFs in Phoenix
Building email systems sounds straightforward until you need to generate custom PDFs, handle attachments, and send thousands of emails reliably. Here’s how I built a flexible email system in Phoenix that compiles responsive HTML emails and generates PDF attachments on the fly.
The Architecture
The system has three main layers:
- View Layer - MJML templates compiled to responsive HTML
- PDF Generation - ChromicPDF for rendering dynamic PDFs with custom styling
- Delivery - Swoosh + Oban for reliable async email delivery
Email Views with MJML
MJML is a markup language designed specifically for responsive emails. It handles all the nightmare cross-client compatibility issues automatically.
View Behavior Pattern
All email views implement a common behavior:
defmodule MyAppMail.Views do
@callback subject(map()) :: String.t()
@callback render(Swoosh.Email.t(), map()) :: Swoosh.Email.t()
end
Each email type has three files:
email_name.ex- View logicemail_name.mjml- Responsive email templateemail_name.text- Plain text fallback
Compile-Time MJML Processing
The clever part is that MJML templates are compiled at build time, not runtime. This macro loads the MJML file, compiles it to HTML, and then compiles it to EEx:
defmacro load_and_compile_mjml() do
quote do
filename = "#{Path.rootname(__ENV__.file)}.mjml"
unless File.exists?(filename) do
raise "Make sure that an MJML template exists at #{filename}"
end
@mjml_file filename
@external_resource filename
# Compile as EEx first to catch template errors with line numbers
EEx.compile_file(filename)
# Then run through MJML compiler
source = MyAppMail.Views.load_mjml(filename)
@mjml EEx.compile_string(source, file: filename, engine: MyAppMail.EexEngine)
end
end
The @external_resource attribute is important - it tells the compiler to recompile this module if the MJML file changes.
MJML Compilation
The MJML compiler runs via NPX at compile time:
def load_mjml(filename) do
case System.cmd(
"npx",
["mjml", "--stdout", filename],
stderr_to_stdout: true
) do
{output, 0} -> output
{_output, exit_code} ->
raise "NPX (or MJML) exited with code #{exit_code}. " <>
"Is MJML available? Did you try running `mix assets.setup`?"
end
end
Example Email View
Here’s a simplified version of an email that sends report attachments:
defmodule MyAppMail.Views.CompanyReport do
use MyAppMail.Views
@impl MyAppMail.Views
def subject(%{type: :daily, formatted_date: date}),
do: "Daily Report - #{date}"
@impl MyAppMail.Views
def subject(%{type: :monthly}),
do: "Monthly Report Summary"
@impl MyAppMail.Views
def render(%Swoosh.Email{} = email, %{report_filepath: path, report_filename: name} = assigns) do
email
|> attach_excel(path, name)
|> Swoosh.Email.put_provider_option(:tag, "company-report")
|> super(assigns) # Calls default rendering with MJML template
end
defp attach_excel(email, filepath, filename) do
Swoosh.Email.attachment(
email,
Swoosh.Attachment.new(filepath,
filename: filename,
content_type: "application/vnd.ms-excel"
)
)
end
end
The corresponding MJML template:
<mjml>
<mj-head>
<mj-include path="partials/head.mjml"/>
</mj-head>
<mj-body background-color="#F5F5F5" width="600px">
<mj-include path="partials/logo.mjml"/>
<mj-wrapper background-color="#ffffff" padding="20px">
<mj-section>
<mj-column>
<mj-text font-size="16px" line-height="24px">
<p>Hello,</p>
<%= cond do %>
<% @type == :monthly -> %>
<p>Your monthly report is attached.</p>
<% @type == :daily -> %>
<p>Your daily report for <%= @formatted_date %> is attached.</p>
<% end %>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
<mj-include path="partials/footer.mjml"/>
</mj-body>
</mjml>
Notice that you can use EEx expressions directly in MJML templates - they’re processed before MJML compilation.
Dynamic PDF Generation
For PDFs, I use ChromicPDF which runs headless Chrome to render HTML to PDF. This gives complete control over styling with CSS.
ChromicPDF Template Pattern
ChromicPDF has a template system for headers, footers, and content:
defmodule PhotosPdf do
def generate_photos_pdf(%{photos: photos, reference: ref, callback: output_path}) do
%{source: source, opts: options} =
%{}
|> add_content(photos, ref)
|> add_footer()
|> add_template_options()
|> apply_chromic_template()
options =
options
|> add_options_info(ref)
|> add_options_callback(output_path)
ChromicPDF.print_to_pdfa(source, options)
end
defp add_content(template_map, photos, reference) do
Map.put(template_map, :content, generate_html(photos, reference))
end
defp generate_html(photos, reference) do
photos_html =
photos
|> Enum.with_index(1)
|> Enum.map_join("\n", fn {photo, index} ->
photo_url = UploadPhoto.url(photo, :large, signed: true)
"""
<div class="photo-container">
<div class="photo-header">
<h3>Photo #{index}</h3>
</div>
<div class="photo-wrapper">
<img src="#{photo_url}" alt="Photo #{index}" />
</div>
</div>
"""
end)
"""
<style>
* {
font-family: Arial, sans-serif;
font-size: 12pt;
}
.photo-container {
margin-bottom: 40px;
page-break-inside: avoid;
}
.photo-header h3 {
color: #2c3e50;
font-size: 16pt;
margin-bottom: 15px;
}
.photo-wrapper {
width: 100%;
text-align: center;
}
.photo-wrapper img {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>
<div class="title-section">
<h1>Photo Documentation</h1>
<p>Reference: #{reference}</p>
</div>
#{photos_html}
"""
end
defp add_footer(template_map) do
Map.put(template_map, :footer, """
<style>
.footer {
font-size: 8pt;
text-align: center;
color: #666;
}
</style>
<div class="footer">
Page <span class="pageNumber"></span> of <span class="totalPages"></span>
</div>
""")
end
defp add_template_options(template_map) do
Map.merge(template_map, %{
size: :a4,
header_height: "15mm",
footer_height: "10mm",
margin_top: "20mm",
margin_bottom: "15mm",
margin_left: "15mm",
margin_right: "15mm"
})
end
defp apply_chromic_template(opts) do
ChromicPDF.Template.source_and_options(
content: opts.content,
footer: opts.footer,
size: opts.size,
header_height: opts.header_height,
footer_height: opts.footer_height
)
end
defp add_options_info(options, reference) do
now = DateTime.utc_now()
options ++ [
info: %{
title: "Photo Documentation - #{reference}",
subject: "Reference #{reference}",
author: "Report System",
creator: "Report System",
creation_date: now,
mod_date: now
}
]
end
defp add_options_callback(options, output_path) do
options ++ [output: output_path]
end
end
CSS for Print Media
The key to good PDFs is understanding CSS print media features:
@page {
size: A4;
margin: 1.5cm;
}
/* Prevent page breaks inside elements */
.photo-container {
page-break-inside: avoid;
}
/* Force page break after element */
.section-end {
page-break-after: always;
}
/* Avoid orphaned headers */
h1,
h2,
h3 {
page-break-after: avoid;
}
/* Keep tables together */
table {
page-break-inside: avoid;
}
ChromicPDF provides special CSS classes for dynamic content:
.pageNumber- Current page number.totalPages- Total page count
Text-Based PDFs
For plain text reports (like calculations), you can render pre-formatted text:
defmodule CalculationPDF do
def generate(calculation_text, assigns) do
rendered = format_for_pdf(calculation_text)
body_html = """
<style>
pre {
font-family: "Courier New", monospace;
font-size: 10pt;
white-space: pre-wrap;
word-wrap: break-word;
}
.page-break {
page-break-after: always;
}
</style>
#{rendered}
"""
%{source: source, opts: options} =
ChromicPDF.Template.source_and_options(
content: body_html,
footer: page_footer(),
size: :a4
)
ChromicPDF.print_to_pdfa(source, options)
end
defp format_for_pdf(text) do
text
|> String.split(~r/^\s*\n---\s*\n\s*\n^/m) # Split on separator
|> Enum.map_join("", &"<div class='page-break'>#{&1}</div>")
|> then(&"<pre>#{&1}</pre>")
end
end
Attaching PDFs to Emails
The email views handle PDF generation and attachment:
defmodule MyAppMail.Views.ReportWithPhotos do
use MyAppMail.Views
@impl MyAppMail.Views
def render(%Swoosh.Email{} = email, %{photos: photos, reference: ref} = assigns) do
email
|> maybe_add_photos_pdf(photos, ref)
|> Swoosh.Email.put_provider_option(:tag, "report-with-photos")
|> super(assigns)
end
defp maybe_add_photos_pdf(email, [], _ref), do: email
defp maybe_add_photos_pdf(email, photos, ref) when length(photos) > 0 do
# Generate PDF to temporary file
temp_file = Path.join(System.tmp_dir!(), "#{Ecto.UUID.generate()}.pdf")
:ok = PhotosPdf.generate_photos_pdf(%{
photos: photos,
reference: ref,
callback: temp_file
})
# Attach to email
Swoosh.Email.attachment(
email,
Swoosh.Attachment.new(temp_file,
filename: "photos_#{ref}.pdf",
content_type: "application/pdf"
)
)
end
end
For in-memory attachments (when you already have the PDF binary):
Swoosh.Email.attachment(
email,
Swoosh.Attachment.new({:data, pdf_binary},
filename: "report.pdf",
content_type: "application/pdf"
)
)
Reliable Delivery with Oban
Emails are sent via Oban workers for reliability and retries:
defmodule Workers.ReportMailer do
use Oban.Worker,
queue: :mailers,
max_attempts: 10
import Swoosh.Email
@impl Oban.Worker
def perform(%Oban.Job{args: %{"report_id" => report_id, "recipient" => recipient}}) do
report = Reports.get_report!(report_id)
email =
new()
|> from({"Report System", "reports@example.com"})
|> to(recipient)
|> MyAppMail.Views.Report.subject(%{report: report})
|> MyAppMail.Views.Report.render(%{report: report})
case Mailer.deliver(email) do
{:ok, _metadata} ->
Logger.info("Email sent successfully to #{recipient}")
:ok
{:error, reason} ->
Logger.error("Failed to send email: #{inspect(reason)}")
{:error, :send_failed}
end
end
end
The worker configuration provides automatic retries with exponential backoff. Oban’s max_attempts: 10 means failed emails will be retried up to 10 times before giving up.
Helper for Consistent Error Handling
I built a helper to handle provider-specific errors:
defmodule Helpers.ObanMailers do
def send_email(email, mailer, email_identifier, config \\ %{}) do
case mailer.deliver(email, config) do
{:ok, _} ->
{:ok, nil}
{:error, {status_code, response}} when status_code in 400..499 ->
# Client errors shouldn't be retried
Logger.error("Email #{email_identifier} failed with client error: #{status_code}")
{:cancel, "Client error: #{status_code}"}
{:error, {status_code, response}} when status_code >= 500 ->
# Server errors should be retried
Logger.warn("Email #{email_identifier} failed with server error: #{status_code}")
{:error, "Server error: #{status_code}"}
{:error, reason} ->
Logger.error("Email #{email_identifier} failed: #{inspect(reason)}")
{:error, reason}
end
end
end
Returning {:cancel, reason} tells Oban not to retry (for permanent failures like invalid addresses), while {:error, reason} triggers retries.
Putting It Together
Here’s a complete flow from generating a report to sending it via email:
defmodule Reports do
def send_report(report_id, recipient_email) do
%{
report_id: report_id,
recipient: recipient_email
}
|> Workers.ReportMailer.new()
|> Oban.insert()
end
end
# Usage
Reports.send_report("abc-123", "user@example.com")
The Oban worker:
- Fetches the report data
- Builds the email with
MyAppMail.Views.Report - The view’s
render/2generates any PDFs and attaches them - Delivers via
Mailer.deliver/1 - Handles errors with automatic retries
Key Takeaways
Compile MJML templates at build time for better performance and early error detection.
Use ChromicPDF with templates for complex PDFs - the header/footer system handles pagination automatically.
Leverage Oban for reliable email delivery with automatic retries and error handling.
Separate concerns - views handle rendering, workers handle delivery, contexts handle business logic.
Use CSS print media features like page-break-inside: avoid for professional PDFs.
The beauty of this system is that adding a new email is just three files (.ex, .mjml, .text) and a worker. The infrastructure handles the rest.