Rendro Integrations

Copy Markdown View Source

Overview

Rendro ships three optional adapters for common ecosystem workflows: threadline (structured audit logging), mailglass (transactional email attachments), and accrue (billing-document recipes). None of them are hard dependencies of Rendro. Each adapter module is compiled only when its target library is present in your application's own mix.exs — if the library is absent, the adapter module does not exist and the Rendro core is entirely unaffected.

This guide walks through enabling each adapter, verifying it works end-to-end, and interpreting the failure modes your code may encounter.

Core layout semantics stay in Rendro

Wrapped flow text and pagination directives are core-library behavior, not adapter-specific behavior. Author width-constrained flow text with Rendro.flow/2, Rendro.block/2, and Rendro.text/2, then use keep_together, keep_with_next, break_before, and break_after on Rendro.Block when a document needs explicit break intent.

Adapters such as Oban workers, audit logging hooks, and mail-delivery helpers only transport or observe the document you already built. They do not add a paragraph DSL, CSS-style fragmentation, widow/orphan control, hyphenation, or adapter-local break semantics. Those scope boundaries come from the Rendro core layout contract and remain the same regardless of delivery path.


Signing

Rendro publishes one canonical signed-artifact recipe for operators: Rendro-authored existing field -> Rendro.Sign.sign/2 via first-party Rendro.Adapters.PyHanko -> Rendro.Sign.validate/2 / pdfsig.

doc =
  Rendro.fixed([
    Rendro.page(
      width: 612,
      height: 792,
      margin_left: 72,
      margin_top: 72,
      blocks: [
        Rendro.signature_field("customer_signature",
          x: 10,
          y: 20,
          width: 180,
          height: 48
        )
      ]
    )
  ])

{:ok, artifact} = Rendro.render_to_artifact(doc, deterministic: true)

{:ok, signed} =
  Rendro.Sign.sign(artifact,
    field: "customer_signature",
    adapter: Rendro.Adapters.PyHanko,
    adapter_opts: [
      key: System.fetch_env!("PYHANKO_KEY_PATH"),
      cert: System.fetch_env!("PYHANKO_CERT_PATH"),
      passfile: System.fetch_env!("PYHANKO_PASSFILE_PATH")
    ]
  )

{:ok, posture} = Rendro.Sign.validate(signed)
assert [%{field: "customer_signature", integrity: :valid}] = posture.signatures

Keep credentials application-owned. Signing is an artifact-stage transform over the original unsigned rendered artifact, not a render-core concern and not a prepared-artifact workflow.

This recipe proves integrity validation only. It does not prove certificate trust, viewer behavior, or compliance posture, and signed output remains non-deterministic.

Use guides/api_stability.md and priv/support_matrix.json for the canonical support boundary vocabulary.


Oban

Rendro.Adapters.Oban.RenderWorker is an optional background-worker boundary for teams that enqueue Rendro renders through Oban. The worker stays intentionally narrow: it accepts a document builder module, builder args, an output path, and an optional bounded-render "policies" map.

Job args contract

The worker consumes only these job args:

%{
  "module" => "Elixir.MyApp.InvoiceDocument",
  "args" => %{"invoice_id" => "inv_123"},
  "output_path" => "/tmp/invoice.pdf",
  "policies" => %{
    "max_pages" => 10,
    "max_bytes" => 2_000_000,
    "timeout" => 15_000
  }
}

"policies" is optional. When present, the worker accepts only "max_pages", "max_bytes", and "timeout". Unknown policy keys fail fast with a typed worker error tuple instead of being silently ignored.

Document-authored policies remain canonical. Worker-provided policies fill only missing doc.options[:policies] keys and never silently override bounds already set by the document builder.

The worker does not support arbitrary job-arg pass-through into doc.options or Rendro.render/2. If you need a broader async contract, build that normalization explicitly in your own job producer before calling Rendro.

The worker also does not accept password or protection fields in job args. Protection secrets do not belong in persisted Oban args. Persist only business identifiers in Oban args. Resolve protection secrets at execution time inside your application boundary.

The canonical protected-delivery recipe is render_to_artifact -> Protect.password -> store/deliver. If you need protected delivery, render the artifact in your worker and apply Rendro.Protect.password/2 in an application-owned secret boundary before storage or delivery.

That recipe is also the release-tail contract for Phoenix and Mailglass users: keep passwords out of persisted Oban args, keep protection inside your application-owned secret boundary, and let Mailglass transport already-protected artifacts rather than password material.

Worker failure diagnostics

Boundary misuse returns typed {:error, reason} tuples without crashing the worker process:

Error tupleWhen it occurs
{:error, {:missing_worker_field, field}}A required job arg such as "module", "args", or "output_path" is absent.
{:error, {:invalid_worker_field, field, value}}A required field or "policies" has the wrong shape.
{:error, {:unknown_worker_module, module}}The named builder module is not available at runtime.
{:error, {:invalid_worker_module, module}}The module exists but does not export build_document/1.
{:error, {:unknown_worker_policy, key}}"policies" contains a key outside the supported bounded-render surface.
{:error, {:invalid_worker_policy, key, value}}A supported policy key is present with an invalid value.

If the worker boundary succeeds but the render itself fails, the worker returns the underlying Rendro reason atom (for example :max_pages_exceeded, :max_bytes_exceeded, or :timeout).


Threadline

Rendro.Adapters.Threadline funnels Rendro render lifecycle events into Threadline.record_action/2 so every render (success or failure) is captured in your Threadline audit trail.

Setup

  1. Add threadline to your application's mix.exs:

    defp deps do
      [
        {:rendro, "~> 0.1"},
        {:threadline, "~> 0.2"},
        # ...
      ]
    end
  2. Attach the handler once at application start (e.g. from Application.start/2):

    defmodule MyApp.Application do
      use Application
    
      def start(_type, _args) do
        Rendro.Adapters.Threadline.attach()
        # ... supervise children
      end
    end

    attach/0 is idempotent — calling it more than once returns :ok without registering a duplicate handler.

The adapter subscribes to:

  • [:rendro, :render, :stop] — emitted after every render, successful or not.
  • [:rendro, :render, :exception] — emitted when the render pipeline crashes.

On :stop with status: :ok it records Threadline.record_action(:render_succeeded, metadata). On :stop with status: :error or on :exception it records Threadline.record_action(:render_failed, metadata).

The metadata forwarded to Threadline contains only the allowlisted telemetry keys (:render_id, :stage, :status, :page_count, :byte_size, :duration, :document_type, :deterministic) plus the nested :error map on failed renders. Document bodies, attachment binaries, and rendered PDFs are never included.

Verification

After attaching the handler, render a document and confirm the audit row arrived:

# docs-contract: integrations-threadline-happy-path
Rendro.Adapters.Threadline.attach()

{:ok, _pdf} = Rendro.render(
  Rendro.flow([Rendro.block(Rendro.text("Test invoice", size: 12))])
)

Rendro.Adapters.Threadline.detach()

The failure-path example below is intentionally schematic. Its public contract is pinned by direct ExUnit semantic tests instead of by a compile-only docs lane.

doc = Rendro.flow(
  [Rendro.block(Rendro.text("x", size: 12))],
  options: %{policies: [max_pages: 0]}
)
{:error, %Rendro.Error{reason: :max_pages_exceeded}} = Rendro.render(doc)

[action | _] = Threadline.list_actions()
assert action.action == :render_failed

To detach the handler (e.g. in test teardown):

Rendro.Adapters.Threadline.detach()

Failure diagnostics

Rendro.Adapters.Threadline.track_render/2 (invoked internally by the telemetry handler) can return the following values:

Return valueWhen it occursWhat to do
:okThreadline.record_action/2 returned :ok or {:ok, _}.Normal; audit row recorded.
{:error, term()}Threadline.record_action/2 returned {:error, reason}.Inspect reason; the Threadline backend declined to record. Check Threadline logs.
{:error, {:unexpected_return, term()}}Threadline.record_action/2 returned something other than :ok, {:ok, _}, or {:error, _}.The Threadline library returned an unexpected shape. Check the Threadline version and its changelog.
{:error, {:exception, Exception.t()}}Threadline.record_action/2 raised an exception; the adapter rescues and wraps it.Inspect the exception struct. Check connectivity/auth to the Threadline backend.

Note: the audit handler is invoked asynchronously from the render pipeline. A non-:ok return from track_render/2 does NOT fail the render — callers still receive their {:ok, pdf} or {:error, %Rendro.Error{}}. If you require guaranteed audit delivery, add monitoring on Threadline's storage directly.

Timeouts are recorded through the same failed-render path as other errors. Rendro.Pipeline.run/1 emits a top-level [:rendro, :render, :stop] event on timeout with status: :error and nested error.kind: :timeout, so Threadline records :render_failed and preserves the timeout subtype in metadata.

That means operators can query one failure surface (:render_failed) and then filter by the nested timeout classification instead of handling a timeout-only action family.


Mailglass

Rendro.Adapters.Mailglass attaches rendered PDF documents to Swoosh emails or Mailglass.Message structs, enabling end-to-end transactional email workflows without leaving the Rendro boundary.

Setup

Add mailglass and swoosh to your application's mix.exs:

defp deps do
  [
    {:rendro, "~> 0.1"},
    {:mailglass, "~> 0.1"},
    {:swoosh, "~> 1.0"},
    # ...
  ]
end

The canonical pipeline is schematic because delivery depends on your own mailer module and deployment setup.

email =
  Swoosh.Email.new()
  |> Swoosh.Email.to("customer@example.test")
  |> Swoosh.Email.subject("Your invoice")

email_with_attachment =
  Rendro.Adapters.Mailglass.attach_pdf(email, doc, "invoice.pdf")

MyApp.Mailer.deliver(email_with_attachment)

Or in a pipe:

email
|> Rendro.Adapters.Mailglass.attach_pdf(doc, "invoice.pdf")
|> case do
  %Swoosh.Email{} = email_with_pdf -> MyApp.Mailer.deliver(email_with_pdf)
  {:error, reason} -> handle_error(reason)
end

If your workflow needs password-to-open delivery, protect the artifact first and then hand the protected artifact to attach_artifact/3:

{:ok, artifact} =
  doc
  |> Rendro.render_to_artifact(deterministic: true)
  |> then(fn {:ok, artifact} ->
    Rendro.Protect.password(artifact,
      open_password: System.fetch_env!("PDF_OPEN_PASSWORD"),
      owner_password: System.fetch_env!("PDF_OWNER_PASSWORD"),
      advisory_permissions: [:print],
      adapter: Rendro.Adapters.Qpdf
    )
  end)

email_with_attachment =
  Rendro.Adapters.Mailglass.attach_artifact(email, artifact, "invoice.pdf")

Protected delivery uses Rendro.Adapters.Mailglass.attach_artifact/3 with an already-protected %Rendro.Artifact{}.

That flow keeps protection at the artifact boundary. Mailglass does not need to know the passwords; it just transports the already-protected PDF bytes.

Verification

Swoosh email path:

# docs-contract: integrations-mailglass-swoosh
doc = Rendro.flow([Rendro.block(Rendro.text("Invoice #001", size: 12))])
email = Swoosh.Email.new() |> Swoosh.Email.to("test@example.test")

result = Rendro.Adapters.Mailglass.attach_pdf(email, doc, "invoice.pdf")

# Confirm the attachment was added
assert length(result.attachments) == 1
[attachment | _] = result.attachments
assert attachment.content_type == "application/pdf"
assert attachment.filename == "invoice.pdf"
assert {:data, pdf} = attachment.data
assert binary_part(pdf, 0, 4) == "%PDF"

Mailglass.Message path:

When the first argument is a %Mailglass.Message{}, attach_pdf/3 extracts the underlying Swoosh email, attaches the PDF to it, and re-wraps the result using Mailglass.Message.update_swoosh/2 if that function is exported:

# docs-contract: integrations-mailglass-message
doc = Rendro.flow([Rendro.block(Rendro.text("Invoice #001", size: 12))])
message = %Mailglass.Message{swoosh: Swoosh.Email.new(), meta: %{campaign_id: "abc"}}

updated_message = Rendro.Adapters.Mailglass.attach_pdf(message, doc, "invoice.pdf")

# The Mailglass wrapper is preserved
assert is_struct(updated_message, Mailglass.Message)
assert length(updated_message.swoosh.attachments) == 1

Failure diagnostics

attach_pdf/3 never raises. All failure paths return {:error, _}:

Error tupleWhen it occursWhat to check
{:error, %Rendro.Error{reason: {:invalid_email_target, value}}}The first argument is neither a %Swoosh.Email{} nor a recognized Mailglass message struct. value echoes the caller's input back for inspection.Ensure the first argument is a %Swoosh.Email{} or a %Mailglass.Message{} (or a struct whose module name ends in .Message and exports update_swoosh/2). Do not pass bare maps, atoms, or other types.
{:error, {:unrecognized_message_shape, struct_module}}The first argument passes the Mailglass-message check (struct ending in .Message exporting update_swoosh/2) but has neither a :swoosh nor an :email field holding a %Swoosh.Email{}. struct_module names the offending struct module.Inspect the custom Mailglass-style struct: it must carry its Swoosh email in a field named :swoosh or :email. If it uses a different field name, implement update_swoosh/2 in a way that reads from that field, or pre-extract the Swoosh email before calling attach_pdf/3.
{:error, %Rendro.Error{}}The document rendering step failed (empty document, max-pages/bytes policy violation, timeout, validation errors, etc.). Inspect :stage and :reason on the %Rendro.Error{}.Check the :stage field (:build, :compose, :measure, :paginate, :render) to locate where the pipeline failed. Check :reason for the specific failure kind (e.g. :max_pages_exceeded, :max_bytes_exceeded, :timeout). Adjust document content or policies accordingly.

Accrue

Rendro.Adapters.Accrue is a billing-document recipe that transforms an %Accrue.Invoice{} into a %Rendro.Document{} ready to be passed to Rendro.render/1. The recipe is pure and composable — it does not render, it only builds the document structure.

Setup

Add accrue to your application's mix.exs:

defp deps do
  [
    {:rendro, "~> 0.1"},
    {:accrue, "~> 0.3"},
    # ...
  ]
end

The recipe entrypoint is pure, but this high-level application example is schematic because MyApp.Billing.fetch_invoice!/1 is app-specific.

invoice = MyApp.Billing.fetch_invoice!(invoice_id)

{:ok, doc} = Rendro.Adapters.Accrue.recipe(invoice)
{:ok, pdf}  = Rendro.render(doc)

Recipe contract

recipe/1 reads the following fields from the %Accrue.Invoice{}:

FieldUsage
:idRendered as "INVOICE #<id>" in the document header.
:customer.name field extracted for "Bill to: <name>" in the header.
:line_itemsList of %Accrue.LineItem{} mapped into a table with explicit column rules for Description, Qty, Unit, and Subtotal. Table layout semantics remain core-library behavior.
:totalRendered as "Total: $<total>" beneath the line-items table.
:issued_atRendered as "Issued: <date>" in the header.

%Accrue.LineItem{} fields consumed:

FieldUsage
:descriptionTable row — Description column.
:quantityTable row — Qty column.
:unit_amountTable row — Unit column.
:subtotalTable row — Subtotal column.

The recipe is the minimum useful mapping. Teams wanting different layouts, additional fields, custom styling, or multi-section documents should treat Rendro.Adapters.Accrue.recipe/1 as a starting template — copy it into your own module and customize from there.

Verification

After calling recipe/1, render the document and verify the output:

# docs-contract: integrations-accrue-verification
invoice = %Accrue.Invoice{
  id: "INV-001",
  issued_at: ~D[2026-04-26],
  customer: %{name: "Acme Corp"},
  line_items: [
    %Accrue.LineItem{description: "Widget", quantity: 2, unit_amount: 50, subtotal: 100}
  ],
  total: 100
}

{:ok, doc} = Rendro.Adapters.Accrue.recipe(invoice)
{:ok, pdf}  = Rendro.render(doc)

# PDF magic bytes confirm a valid PDF was produced
assert binary_part(pdf, 0, 4) == "%PDF"

# Inspect the document to confirm the invoice id is present
assert inspect(doc) =~ "INV-001"

Failure diagnostics

recipe/1 can return the following errors:

Error tupleWhen it occursWhat to check
{:error, {:invalid_invoice, term()}}The argument is not an %Accrue.Invoice{} struct. The second element echoes the caller's input for inspection.Ensure the input is an %Accrue.Invoice{} fetched from the Accrue library. Do not pass plain maps, keyword lists, or other structs.

Render-time errors flow through Rendro.render/1 (not recipe/1) and produce {:error, %Rendro.Error{}} with :stage in one of :build, :compose, :measure, :paginate, or :render. Inspect the %Rendro.Error{} fields for detail:

FieldMeaning
:stagePipeline stage where the failure occurred.
:reasonStructured reason atom or tuple (e.g. :max_pages_exceeded, :timeout).

Optional-dependency discipline

None of threadline, mailglass, or accrue appear in Rendro's own mix.exs dependencies. Each adapter module is wrapped in a compile-time guard:

if Code.ensure_loaded?(Threadline) do
  defmodule Rendro.Adapters.Threadline do
    def attach, do: :ok
  end
end

When the library is absent from the application's deps, the guard evaluates to false at compile time, the defmodule block is skipped entirely, and the adapter module does not exist. Core Rendro behavior is completely unaffected.

Maintainers should NOT add :threadline, :mailglass, or :accrue to Rendro's own mix.exs deps. Users add them to their own application's mix.exs; Rendro simply detects their presence at compile time.

Test-time recompilation: In Rendro's own test suite, the adapter modules need to be exercisable without adding the ecosystem libraries as real dependencies. This is accomplished via a two-step mechanism in test/support/mocks.ex:

  1. Minimal stub modules for Threadline, Mailglass, Mailglass.Message, Swoosh.Email, Swoosh.Attachment, Accrue, Accrue.Invoice, and Accrue.LineItem are defined in test/support/mocks.ex. These stubs satisfy Code.ensure_loaded?/1 checks during the test compile.

  2. AdapterReloader.recompile/0 (called from test/test_helper.exs after ExUnit.start/0) re-evaluates each adapter file with the stub modules already loaded, so the guarded module bodies are compiled and available during test runs.

This design lets CI exercise all adapter code paths without any real ecosystem library installed, preserving the "Rendro core has no ecosystem deps" guarantee from production builds. See test/support/mocks.ex for the full stub definitions.