Module: Protege::Gateway

Extended by:
Gateway
Included in:
Gateway
Defined in:
lib/protege/gateway.rb,
lib/protege/gateway/mail/address.rb,
lib/protege/gateway/access_policy.rb,
lib/protege/gateway/mail/outbound.rb,
lib/protege/gateway/access_control.rb,
lib/protege/gateway/mail/attachment.rb,
lib/protege/gateway/mail/message_id.rb,
lib/protege/gateway/mail/references.rb,
lib/protege/gateway/attachment_policy.rb

Overview

Namespace for all inbound/outbound email concerns — mail parsing, address handling, delivery transport.

Module methods here are the cross-namespace entry points. Other namespaces (Orchestrator, Loop) and the Rails layer call these rather than reaching into the Mail::* value objects or AccessControl.

Defined Under Namespace

Modules: Mail Classes: AccessControl, AccessPolicy, AttachmentPolicy

Constant Summary collapse

RECURSION_HEADER =

The hop-count header agent-sent mail carries so agent-to-agent reply loops stay bounded. Every email an agent sends is stamped: 1 on fresh mail, the inbound value plus one on a reply. Human mail clients never echo a custom header back, so a conversation with a person always resets — only an unbroken chain of Protege-to-Protege replies accumulates hops, and the inbound edge (+AgentMailbox+) silently drops mail whose count reaches its limit.

'X-Protege-Recursion'

Instance Method Summary collapse

Instance Method Details

#accept_console_message(mail:, agent:, attachments: []) ⇒ Message

Accept a message typed into the dashboard console.

Finds or creates the EmailThread for mail, persists an inbound Message marked :pending, and enqueues a ConsoleInferenceJob (delayed one second so the record is committed and visible before the job runs). The mail.message_id is propagated as the correlation id so events across the run can be traced.

Parameters:

  • mail (::Mail::Message)

    the console-authored message.

  • agent (Agent)

    the agent receiving the message.

  • attachments (Array<ActiveStorage::Blob, ActiveStorage::Attachment, #original_filename, Hash>) (defaults to: [])

    files for the agent to read. Each entry is normalized to a blob (see #blob_for) and attached by reference (mirroring deliver) — an already-stored blob/attachment reuses its blob, an uploaded file or a { filename:, content_type:, bytes: } Hash is uploaded once. Attached after save! so they are committed before the (delayed) job reads the message; the agent sees them via Message#content_parts / the read_attachment tool.

Returns:

  • (Message)

    the saved inbound message record.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/protege/gateway.rb', line 45

def accept_console_message(mail:, agent:, attachments: [])
  correlation_id            = Mail::MessageId.ensure(mail.message_id).value
  thread                    = EmailThread.find_or_create_for(mail:, agent:)
  message                   = Message.build_from_mail(mail:, direction: :inbound, thread:, agent:)
  message.processing_status = :pending
  message.save!

  # Drop blank entries before coercing: a console file field submits a stray "" when nothing is
  # chosen (an empty multipart part Rack parses as an empty string). Filtering here — at the shared
  # array intake — keeps every caller (reply, new thread, host Gateway.message) clean while blob_for
  # stays strict for genuinely-unsupported values.
  files = attachments.compact_blank
  message.attachments.attach(files.map { blob_for(_1) }) if files.any?

  ConsoleInferenceJob
    .set(wait: 1.second)
    .perform_later(message_id:     message.id,
                   agent_id:       agent.id,
                   correlation_id:)

  message
end

#accept_smtp_message(inbound_email:, agent:) ⇒ void

This method returns an undefined value.

Accept an inbound email arriving via SMTP / Action Mailbox.

Unlike the console path, ingestion (persisting the Message, building the thread) happens inside InferenceJob; this method only enqueues that job with the agent and the inbound email reference, carrying the mail's message id as the correlation id.

Parameters:

  • inbound_email (ActionMailbox::InboundEmail)

    the ingress email record.

  • agent (Agent)

    the agent the message was routed to.



119
120
121
122
123
124
125
# File 'lib/protege/gateway.rb', line 119

def accept_smtp_message(inbound_email:, agent:)
  InferenceJob.perform_later(
    agent_id:         agent.id,
    inbound_email_id: inbound_email.id,
    correlation_id:   Mail::MessageId.ensure(inbound_email.mail.message_id).value
  )
end

#build_access_policyProtege::Gateway::AccessPolicy

Build an inbound access policy — one layer of the sender-authorization guardrail. The public way to construct an AccessPolicy, whether the global ceiling (+config.inbound_access+) or a per-agent layer (+AccessRule.policy_for+). build_ marks it as an instance factory, not a persisted record. Keyword arguments are forwarded (+allow:+, deny:, default_decision:).

Returns:



176
177
178
# File 'lib/protege/gateway.rb', line 176

def build_access_policy(**)
  AccessPolicy.new(**)
end

#build_attachment_policyProtege::Gateway::AttachmentPolicy

Build an attachment policy — the inbound/outbound attachment limits enforced at the mail edges. The public way to construct an AttachmentPolicy (assigned as config.attachment_policy). build_ marks it as an instance factory, not a persisted record. Keyword arguments are forwarded (+enabled:+, max_bytes:, max_count:, max_total_bytes:).

Returns:



186
187
188
# File 'lib/protege/gateway.rb', line 186

def build_attachment_policy(**)
  AttachmentPolicy.new(**)
end

#build_outbound::Mail::Message

Build an outbound ::Mail::Message with Protege's threading/id/date conventions. The single place callers (controllers, the send-email tool) assemble outbound mail without touching the Mail::Outbound builder directly. Keyword arguments are forwarded to the builder (+from:+, to:, subject:, body:, cc:, bcc:, in_reply_to:, references:, attachments:).

Returns:

  • (::Mail::Message)

    the composed outbound mail



166
167
168
# File 'lib/protege/gateway.rb', line 166

def build_outbound(**)
  Mail::Outbound.build(**)
end

#canonical_message_id(raw) ⇒ String

Normalize a raw Message-ID header to its canonical, angle-bracketed value.

Parameters:

  • raw (String, nil)

    the raw Message-ID (or In-Reply-To) value

Returns:

  • (String)

    the canonical id



194
195
196
# File 'lib/protege/gateway.rb', line 194

def canonical_message_id(raw)
  Mail::MessageId.ensure(raw).value
end

#claims?(inbound_email:) ⇒ Boolean

Report whether an inbound email is addressed to a domain Protege receives for — the Action Mailbox routing predicate. Domain-scoped on purpose: the engine claims only mail for its own domains and leaves everything else to the host's own mailbox routing, so mounting Protege never hijacks a host's other inbound mail. Per-agent/subaddress validation stays in AgentMailbox.

Considers both To and Cc: an agent reached only as a Cc recipient (e.g. on a reply-all) is a legitimate participant and must be claimed, matching how the rest of the system treats Cc.

Parameters:

  • inbound_email (ActionMailbox::InboundEmail)

    the received email

Returns:

  • (Boolean)

    true when any To/Cc recipient's domain is one of ours



244
245
246
247
# File 'lib/protege/gateway.rb', line 244

def claims?(inbound_email:)
  mail = inbound_email.mail
  (Array(mail.to) + Array(mail.cc)).any? { |address| EmailDomain.receives?(address) }
end

#deliver(mail:, agent:, attachments: []) ⇒ Message

Deliver an outbound message on behalf of an agent and record it.

Sends over SMTP via mail.deliver! unless the recipient is the local console address, in which case the conversation stays entirely in-app. Either way the message is persisted as an :outbound Message on its thread with delivered_at stamped to now.

Attachments are persisted by reference: the outbound record does not rebuild them from the mail's MIME parts (which would mint a duplicate blob) — instead the caller passes the attachments (blobs already in storage) and they are attached directly. So a file, wherever it came from, is uploaded once and thereafter only referenced.

Parameters:

  • mail (::Mail::Message)

    the outbound mail to deliver.

  • agent (Agent)

    the sending agent.

  • attachments (Array<ActiveStorage::Blob>) (defaults to: [])

    blobs to attach to the outbound record by reference.

Returns:

  • (Message)

    the saved outbound message record.



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/protege/gateway.rb', line 142

def deliver(mail:, agent:, attachments: [])
  deliver_over_transport(mail) unless local_delivery?(mail)

  thread = EmailThread.find_or_create_for(mail:, agent:)

  Message.build_from_mail(
    mail:,
    thread:,
    agent:,
    direction:        :outbound,
    attach_from_mail: false
  ).tap do |record|
    record.delivered_at = Time.current
    record.attachments.attach(attachments) if attachments.any?
    record.save!
  end
end

#mail_accessory_hostString?

The self-hosted mail accessory's network name, or nil. Reuses the existing setting that already names the accessory for outbound SMTP (+config.action_mailer.smtp_settings+ = ENV['MAIL_RELAY_HOST']) — no separate config knob. Its presence is also the self-hosted signal: unset means provider mode, where there is no MTA to provision.

Returns:

  • (String, nil)

    the accessory host, or nil when not self-hosting



267
268
269
# File 'lib/protege/gateway.rb', line 267

def mail_accessory_host
  ENV['MAIL_RELAY_HOST'].presence
end

#mail_provisioning_manifestHash

The full set of signing material the self-hosted MTA needs — every EmailDomain with its DKIM key. The app pushes this whole manifest to the mail accessory on any domain change (see #push_mail_provisioning); the accessory rewrites its OpenDKIM/Postfix config from it, so the manifest is always complete state, never a delta.

Returns:

  • (Hash)

    { domains: [...] }, one entry per domain (see EmailDomain#to_provisioning)



257
258
259
# File 'lib/protege/gateway.rb', line 257

def mail_provisioning_manifest
  { domains: EmailDomain.all.map(&:to_provisioning) }
end

#message(to:, body:, subject: nil, attachments: []) ⇒ Message

Send a message to an agent from the host application — the reverse of tools/resolvers.

Where tools and resolvers let the agent reach into the app, this lets ordinary application code (a service object, controller, or state machine) hand the agent something to act on:

Protege::Gateway.message(to: 'security@acme.com', subject: 'Large transfer flagged',
                       body: 'Review activity for user #678; alert ops@ if suspicious.')

It is a thin convenience over build_outbound + accept_console_message — the exact sequence the dashboard console uses — so the brief flows through the same "inbound message starts a thread" path (+ReplyHarness+ and the agent's message_resolvers). to is an address, not a record: it is resolved through Agent.lookup (honouring case and tag subaddressing), so a caller can pass a literal address or agent.email_address without loading the record first.

Fire-and-forget: it returns the persisted inbound Message for reference/tracing, not an LLM response — the agent's actions (any mail it chooses to send) are the outcome. The agent reads it, reasons, and uses its (scoped) tools; there is nothing it must reply to.

Attachments meet the host where its file already is — a stored blob/attachment (e.g. transaction.receipt, reused by reference), an ActionDispatch::Http::UploadedFile (a controller upload, passed straight through), or a plain { filename:, content_type:, bytes: } Hash (raw/ generated bytes, uploaded once). So a caller never constructs a Gateway::Mail::Attachment by hand. See #blob_for for the full set of accepted shapes.

Parameters:

  • to (String)

    the recipient agent's address (or agent.email_address).

  • subject (String, nil) (defaults to: nil)

    the message subject (the thread's title).

  • body (String)

    the brief the agent acts on.

  • attachments (Array) (defaults to: [])

    files for the agent to read; see #accept_console_message / #blob_for.

Returns:

  • (Message)

    the saved inbound message record.

Raises:

  • (ArgumentError)

    when no active agent owns to, or an attachment is an unsupported shape.



98
99
100
101
102
103
104
105
106
107
108
# File 'lib/protege/gateway.rb', line 98

def message(to:, body:, subject: nil, attachments: [])
  agent = Agent.lookup(to)
  raise ArgumentError, "no active agent for address: #{to.inspect}" unless agent

  mail = build_outbound(from:    Protege.configuration.console_address,
                        to:      agent.email_address,
                        subject:,
                        body:)

  accept_console_message(mail:, agent:, attachments:)
end

#parse_message_id(raw) ⇒ String?

Parse a single Message-ID header to its canonical value (nil-safe on blank input).

Parameters:

  • raw (String, nil)

    the raw header value

Returns:

  • (String, nil)

    the canonical id, or nil when blank



202
203
204
205
206
# File 'lib/protege/gateway.rb', line 202

def parse_message_id(raw)
  return nil if raw.blank?

  Mail::MessageId.parse(raw).value
end

#permits?(agent:, address:) ⇒ Boolean

Report whether a sender may reach an agent, per the agent's inbound access guardrail.

Parameters:

  • agent (Agent)

    the routed agent

  • address (String, nil)

    the sender's address

Returns:

  • (Boolean)

    true when every access layer permits the sender



230
231
232
# File 'lib/protege/gateway.rb', line 230

def permits?(agent:, address:)
  AccessControl.for(agent:).permits?(address:)
end

#push_mail_provisioningHash

Push the whole provisioning manifest to the mail accessory's internal control listener, which writes the DKIM keys + Postfix tables and reloads. Event-driven (called from MailProvisioningJob on any EmailDomain change, and by the console "Sync now" button) — no polling, no public endpoint. Authenticates with the already-shared RAILS_INBOUND_EMAIL_PASSWORD.

A no-op in provider mode (no accessory host). Never raises: a domain save or a button click must not break because the MTA is momentarily down — failures are logged and reported in the result (the job's retry, or the button, re-pushes).

Returns:

  • (Hash)

    { ok:, count:, message: } describing the outcome



281
282
283
284
285
286
287
288
289
290
291
292
# File 'lib/protege/gateway.rb', line 281

def push_mail_provisioning
  host = mail_accessory_host
  return provisioning_result(success: false, count: 0, message: 'No mail accessory configured') if host.blank?

  manifest = mail_provisioning_manifest
  deliver_provisioning(host:, manifest:)
  count    = manifest[:domains].size
  provisioning_result(success: true, count:, message: "Pushed #{count} domain(s) to the mail server")
rescue StandardError => e
  Protege.configuration.logger.warn("[protege] mail provisioning push failed: #{e.class}: #{e.message}")
  provisioning_result(success: false, count: 0, message: "Mail server unreachable: #{e.message}")
end

#reference_ids(items) ⇒ Array<String>

Parse a References / In-Reply-To chain into an ordered list of canonical id strings.

Parameters:

  • items (Array<String>, String, nil)

    the raw header value(s)

Returns:

  • (Array<String>)

    the ordered canonical ids



212
213
214
# File 'lib/protege/gateway.rb', line 212

def reference_ids(items)
  Mail::References.parse(Array(items)).map(&:to_s)
end

#routing_key(address) ⇒ String

The routing key (tag-stripped, lowercased local@domain) for an inbound recipient address.

Parameters:

  • address (String, nil)

    the raw email address

Returns:

  • (String)

    the routing key

Raises:

  • (ArgumentError)

    when the address is blank or malformed



221
222
223
# File 'lib/protege/gateway.rb', line 221

def routing_key(address)
  Mail::Address.parse(address).routing_key
end