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 inmailgun-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:
- 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 RubyArgumentError, not a silent send. - Requires the entity reference as
entity_uuid:andentity_kind:keyword arguments (or a singleentity:argument that the service introspects). Composesv:<entity_kind>_uuid=<entity_uuid>automatically. - Validates the recipient and other Mailgun fields the way the existing wrappers do, in one place.
- Returns the synchronous Mailgun response so callers can audit it (per the
email_provider_responsejsonb pattern in the billing invoice spec). - Forbids direct calls to
Mailgun::Client#send_messageby 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:
- Each mailer class replaces its direct
Mailgun::Clientcall withMailgun::SendService.execute(...). - The
email_typekeyword arg is set to the value listed inmailgun-overview.mdfor that mailer. - 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 allowsentity: niland 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.executecovers all kinds. An alternative is per-kind services (Mailgun::SendInvoiceService, etc.), but that creates churn and duplicates logic. Single service preferred. - How is
EMAIL_TYPESkept in sync withmailgun-overview.md? Two options: (a) a single source of truth in code (Mailgun::EmailTypesmodule) 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.executewas called with the rightemail_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 types30-49-domains/40-billing/models/billing-invoice.md— UC-2b shows the custom-variable contract- Tracking issue: jod-app/jodapp-api#1563