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

Instance Method Summary collapse

Instance Method Details

#accept_console_message(mail:, persona:, 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.

  • persona (Persona)

    the persona receiving the message.

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

    files for the persona 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 persona sees them via Message#content_parts / the read_attachment tool.

Returns:

  • (Message)

    the saved inbound message record.



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/protege/gateway.rb', line 38

def accept_console_message(mail:, persona:, attachments: [])
  correlation_id            = Mail::MessageId.ensure(mail.message_id).value
  thread                    = EmailThread.find_or_create_for(mail:, persona:)
  message                   = Message.build_from_mail(mail:, direction: :inbound, thread:, persona:)
  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,
                   persona_id:     persona.id,
                   correlation_id:)

  message
end

#accept_smtp_message(inbound_email:, persona:) ⇒ 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 persona and the inbound email reference, carrying the mail's message id as the correlation id.

Parameters:

  • inbound_email (ActionMailbox::InboundEmail)

    the ingress email record.

  • persona (Persona)

    the persona the message was routed to.



112
113
114
115
116
117
118
# File 'lib/protege/gateway.rb', line 112

def accept_smtp_message(inbound_email:, persona:)
  InferenceJob.perform_later(
    persona_id:       persona.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-persona layer (+AccessRule.policy_for+). build_ marks it as an instance factory, not a persisted record. Keyword arguments are forwarded (+allow:+, deny:, default_decision:).

Returns:



169
170
171
# File 'lib/protege/gateway.rb', line 169

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:



179
180
181
# File 'lib/protege/gateway.rb', line 179

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



159
160
161
# File 'lib/protege/gateway.rb', line 159

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



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

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-persona/subaddress validation stays in AgentMailbox.

Parameters:

  • inbound_email (ActionMailbox::InboundEmail)

    the received email

Returns:

  • (Boolean)

    true when any recipient's domain is one of ours



234
235
236
# File 'lib/protege/gateway.rb', line 234

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

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

Deliver an outbound message on behalf of a persona 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.

  • persona (Persona)

    the sending persona.

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

    blobs to attach to the outbound record by reference.

Returns:

  • (Message)

    the saved outbound message record.



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/protege/gateway.rb', line 135

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

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

  Message.build_from_mail(
    mail:,
    thread:,
    persona:,
    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



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

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)



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

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

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

Send a message to a persona 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 persona's message_resolvers). to is an address, not a record: it is resolved through Persona.lookup (honouring case and tag subaddressing), so a caller can pass a literal address or persona.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 persona 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 persona's address (or persona.email_address).

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

    the message subject (the thread's title).

  • body (String)

    the brief the persona acts on.

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

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

Returns:

  • (Message)

    the saved inbound message record.

Raises:

  • (ArgumentError)

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



91
92
93
94
95
96
97
98
99
100
101
# File 'lib/protege/gateway.rb', line 91

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

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

  accept_console_message(mail:, persona:, 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



195
196
197
198
199
# File 'lib/protege/gateway.rb', line 195

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

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

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

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

Parameters:

  • persona (Persona)

    the routed persona

  • address (String, nil)

    the sender's address

Returns:

  • (Boolean)

    true when every access layer permits the sender



223
224
225
# File 'lib/protege/gateway.rb', line 223

def permits?(persona:, address:)
  AccessControl.for(persona:).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



270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/protege/gateway.rb', line 270

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



205
206
207
# File 'lib/protege/gateway.rb', line 205

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



214
215
216
# File 'lib/protege/gateway.rb', line 214

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