Skip to main content

Mailgun send wrapper service — design proposal

Status: Proposal. Not yet implemented. Tracking issue: jod-app/jodapp-api#1563.

Problem

Every Mailgun send call in the Jod codebase must include two custom variables:

  • v:email_type — one of the enum values listed in mailgun-overview.md. Lets the webhook handler route delivery events to the right domain handler.
  • v:<entity>_uuid (where applicable) — the UUID of the entity the email refers to (e.g. v:invoice_uuid, v:campaign_uuid). Lets the webhook handler resolve the source record.

Today, every mailer class calls Mailgun::Client#send_message directly. There is no enforcement that the custom variables are present or correctly named. A developer adding a new email type — six months from now, possibly under deadline pressure — is one forgotten line away from sending events that the webhook handler cannot route.

The cost of forgetting:

  • Webhook events arrive with no email_type. The handler returns 200 OK and drops them. The bug is silent.
  • Delivery, bounce, and complaint signals for the new email type are invisible to the system.
  • Discovered weeks or months later, when ops asks "why didn't we see that bounce?".

Proposed approach

Introduce a Mailgun::Send service. All mailer classes call this service instead of Mailgun::Client directly. The service:

  1. Requires email_type: as a keyword argument. The argument type is an enum (or a constant from a single source of truth). A typo or missing value is a Ruby ArgumentError, not a silent send.
  2. Requires the entity reference as entity_uuid: and entity_kind: keyword arguments (or a single entity: argument that the service introspects). Composes v:<entity_kind>_uuid=<entity_uuid> automatically.
  3. Validates the recipient and other Mailgun fields the way the existing wrappers do, in one place.
  4. Returns the synchronous Mailgun response so callers can audit it (per the email_provider_response jsonb pattern in the billing invoice spec).
  5. Forbids direct calls to Mailgun::Client#send_message by linting (Rubocop custom cop) or convention. The wrapper is the only entry point.

Interface sketch

# app/services/mailgun/send_service.rb
module Mailgun
class SendService
EMAIL_TYPES = %w[
user_otp_signup
user_otp_password_reset
admin_invite
ads_campaign_created
ads_campaign_approved
ads_campaign_rejected
careers_application_submitted
careers_application_stage_updated
jodgig_work_timing_adjustments
billing_invoice_issued
].freeze

def self.execute(email_type:, entity:, to:, from:, subject:, html:, attachments: [])
raise ArgumentError, "Unknown email_type: #{email_type}" unless EMAIL_TYPES.include?(email_type)

entity_kind = entity.class.name.demodulize.underscore # e.g. "invoice"
custom_vars = {
"v:email_type" => email_type,
"v:#{entity_kind}_uuid" => entity.uuid,
}

Mailgun::Client.new(...).send_message(
domain,
{
to: to,
from: from,
subject: subject,
html: html,
attachment: attachments,
**custom_vars,
},
)
end
end
end

This is a sketch, not a final API. The implementing engineer should refine it after reading the existing mailer classes.

Migration

The codebase has 9 mailer call sites today (per mailgun-overview.md). Migration is mechanical:

  1. Each mailer class replaces its direct Mailgun::Client call with Mailgun::SendService.execute(...).
  2. The email_type keyword arg is set to the value listed in mailgun-overview.md for that mailer.
  3. The entity: arg is set to whichever model the email is about (invoice, campaign, application, etc.). For mailers that don't have a single source entity (rare), the wrapper allows entity: nil and skips the entity-uuid variable.

Migration can be incremental — one mailer per PR is fine. The wrapper does not break existing direct calls during the transition; the lint/cop is added at the end, when all mailers are migrated.

Open questions

  • Where does the service live? Likely app/services/mailgun/ to match Jod's service layout. Implementing engineer to confirm.
  • One service or one per mailer kind? A single Mailgun::SendService.execute covers all kinds. An alternative is per-kind services (Mailgun::SendInvoiceService, etc.), but that creates churn and duplicates logic. Single service preferred.
  • How is EMAIL_TYPES kept in sync with mailgun-overview.md? Two options: (a) a single source of truth in code (Mailgun::EmailTypes module) that the doc references; (b) the doc is canonical and a CI check parses it. Option (a) is simpler.
  • Test strategy. Each mailer's spec asserts that Mailgun::SendService.execute was called with the right email_type. The service's spec asserts that custom variables are composed correctly.
  • Rollout for production. No data migration is required (the change is in send-call signatures, not persisted state). A standard rolling deploy works.

References

  • mailgun-overview.md — list of email types
  • 30-49-domains/40-billing/models/billing-invoice.md — UC-2b shows the custom-variable contract
  • Tracking issue: jod-app/jodapp-api#1563