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
-
#accept_console_message(mail:, persona:, attachments: []) ⇒ Message
Accept a message typed into the dashboard console.
-
#accept_smtp_message(inbound_email:, persona:) ⇒ void
Accept an inbound email arriving via SMTP / Action Mailbox.
-
#build_access_policy ⇒ Protege::Gateway::AccessPolicy
Build an inbound access policy — one layer of the sender-authorization guardrail.
-
#build_attachment_policy ⇒ Protege::Gateway::AttachmentPolicy
Build an attachment policy — the inbound/outbound attachment limits enforced at the mail edges.
-
#build_outbound ⇒ ::Mail::Message
Build an outbound
::Mail::Messagewith Protege's threading/id/date conventions. -
#canonical_message_id(raw) ⇒ String
Normalize a raw Message-ID header to its canonical, angle-bracketed value.
-
#claims?(inbound_email:) ⇒ Boolean
Report whether an inbound email is addressed to a domain Protege receives for — the Action Mailbox routing predicate.
-
#deliver(mail:, persona:, attachments: []) ⇒ Message
Deliver an outbound message on behalf of a persona and record it.
-
#mail_accessory_host ⇒ String?
The self-hosted mail accessory's network name, or nil.
-
#mail_provisioning_manifest ⇒ Hash
The full set of signing material the self-hosted MTA needs — every
EmailDomainwith its DKIM key. -
#message(to:, body:, subject: nil, attachments: []) ⇒ Message
Send a message to a persona from the host application — the reverse of tools/resolvers.
-
#parse_message_id(raw) ⇒ String?
Parse a single Message-ID header to its canonical value (nil-safe on blank input).
-
#permits?(persona:, address:) ⇒ Boolean
Report whether a sender may reach a persona, per the persona's inbound access guardrail.
-
#push_mail_provisioning ⇒ Hash
Push the whole provisioning manifest to the mail accessory's internal control listener, which writes the DKIM keys + Postfix tables and reloads.
-
#reference_ids(items) ⇒ Array<String>
Parse a References / In-Reply-To chain into an ordered list of canonical id strings.
-
#routing_key(address) ⇒ String
The routing key (tag-stripped, lowercased
local@domain) for an inbound recipient address.
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.
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 (mail:, persona:, attachments: []) correlation_id = Mail::MessageId.ensure(mail.).value thread = EmailThread.find_or_create_for(mail:, persona:) = Message.build_from_mail(mail:, direction: :inbound, thread:, persona:) .processing_status = :pending .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 = .compact_blank ..attach(files.map { blob_for(_1) }) if files.any? ConsoleInferenceJob .set(wait: 1.second) .perform_later(message_id: .id, persona_id: persona.id, correlation_id:) 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.
112 113 114 115 116 117 118 |
# File 'lib/protege/gateway.rb', line 112 def (inbound_email:, persona:) InferenceJob.perform_later( persona_id: persona.id, inbound_email_id: inbound_email.id, correlation_id: Mail::MessageId.ensure(inbound_email.mail.).value ) end |
#build_access_policy ⇒ Protege::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:).
169 170 171 |
# File 'lib/protege/gateway.rb', line 169 def build_access_policy(**) AccessPolicy.new(**) end |
#build_attachment_policy ⇒ Protege::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:).
179 180 181 |
# File 'lib/protege/gateway.rb', line 179 def (**) 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:).
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.
187 188 189 |
# File 'lib/protege/gateway.rb', line 187 def (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.
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.
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..attach() if .any? record.save! end end |
#mail_accessory_host ⇒ String?
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.
256 257 258 |
# File 'lib/protege/gateway.rb', line 256 def mail_accessory_host ENV['MAIL_RELAY_HOST'].presence end |
#mail_provisioning_manifest ⇒ Hash
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.
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.(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.
91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/protege/gateway.rb', line 91 def (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:) (mail:, persona:, attachments:) end |
#parse_message_id(raw) ⇒ String?
Parse a single Message-ID header to its canonical value (nil-safe on blank input).
195 196 197 198 199 |
# File 'lib/protege/gateway.rb', line 195 def (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.
223 224 225 |
# File 'lib/protege/gateway.rb', line 223 def permits?(persona:, address:) AccessControl.for(persona:).permits?(address:) end |
#push_mail_provisioning ⇒ Hash
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).
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.}") provisioning_result(success: false, count: 0, message: "Mail server unreachable: #{e.}") end |
#reference_ids(items) ⇒ Array<String>
Parse a References / In-Reply-To chain into an ordered list of canonical id strings.
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.
214 215 216 |
# File 'lib/protege/gateway.rb', line 214 def routing_key(address) Mail::Address.parse(address).routing_key end |