Building a Production Email System with Dynamic PDFs in Phoenix

elixir phoenix email pdf mjml chromicpdf swoosh oban

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:

  1. View Layer - MJML templates compiled to responsive HTML
  2. PDF Generation - ChromicPDF for rendering dynamic PDFs with custom styling
  3. 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 logic
  • email_name.mjml - Responsive email template
  • email_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:

  1. Fetches the report data
  2. Builds the email with MyAppMail.Views.Report
  3. The view’s render/2 generates any PDFs and attaches them
  4. Delivers via Mailer.deliver/1
  5. 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.