protege 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mail'
4
+
5
+ module Protege
6
+ module Gateway
7
+ module Mail
8
+ # Builder for outbound +::Mail::Message+ objects — the one place the engine assembles mail it
9
+ # sends, whether a brand-new console conversation or a threaded reply.
10
+ #
11
+ # Centralizing construction keeps the conventions (a synthetic angle-bracketed Message-ID, a
12
+ # UTF-8 plain-text body, a Date stamp) and the threading-header normalization (+In-Reply-To+ via
13
+ # +MessageId+, +References+ via +References+) in a single tested spot, rather than duplicated
14
+ # across +RepliesController+, +ThreadsController+, and host tools like +SendEmailTool+.
15
+ #
16
+ # @example A threaded reply
17
+ # Outbound.build(
18
+ # from: persona.email_address,
19
+ # to: inbound.from_address,
20
+ # subject: 'Re: Hello',
21
+ # body: 'Thanks!',
22
+ # in_reply_to: inbound.message_id,
23
+ # references: inbound.references_header
24
+ # )
25
+ class Outbound
26
+ class << self
27
+ # Build and return a ready-to-deliver +::Mail::Message+.
28
+ #
29
+ # @param from [String] the sender address.
30
+ # @param to [String, Array<String>] the recipient address(es).
31
+ # @param subject [String] the subject line.
32
+ # @param body [String] the plain-text body.
33
+ # @param cc [String, Array<String>] carbon-copy recipient(s); omitted from the message when empty.
34
+ # @param bcc [String, Array<String>] blind-carbon-copy recipient(s); omitted when empty.
35
+ # @param in_reply_to [String, MessageId, nil] the parent message id, normalized when present.
36
+ # @param references [References, Array<String>, String, nil] the prior reference chain;
37
+ # Strings are split on whitespace, Arrays/References normalized per id.
38
+ # @param attachments [Array<Mail::Attachment>] files to attach; when any are present the message
39
+ # is assembled as multipart with the body as a text part.
40
+ # @return [::Mail::Message] the assembled message.
41
+ def build(
42
+ from:,
43
+ to:,
44
+ subject:,
45
+ body:,
46
+ cc: [],
47
+ bcc: [],
48
+ in_reply_to: nil,
49
+ references: nil,
50
+ attachments: []
51
+ )
52
+ new(
53
+ from:,
54
+ to:,
55
+ subject:,
56
+ body:,
57
+ cc:,
58
+ bcc:,
59
+ in_reply_to:,
60
+ references:,
61
+ attachments:
62
+ ).message
63
+ end
64
+ end
65
+
66
+ # Store the envelope and threading inputs. Prefer the +.build+ factory.
67
+ #
68
+ # @param (see .build)
69
+ def initialize(
70
+ from:,
71
+ to:,
72
+ subject:,
73
+ body:,
74
+ cc: [],
75
+ bcc: [],
76
+ in_reply_to: nil,
77
+ references: nil,
78
+ attachments: []
79
+ )
80
+ @from = from
81
+ @to = to
82
+ @subject = subject
83
+ @body = body
84
+ @cc = cc
85
+ @bcc = bcc
86
+ @in_reply_to = in_reply_to
87
+ @references = references
88
+ @attachments = attachments
89
+ end
90
+
91
+ # Assemble the +::Mail::Message+ from the stored inputs.
92
+ #
93
+ # @return [::Mail::Message] the message with envelope, body, id, date, and threading headers set.
94
+ def message
95
+ ::Mail.new.tap do |m|
96
+ m.from = @from
97
+ m.to = @to
98
+ m.cc = @cc
99
+ m.bcc = @bcc
100
+ m.subject = @subject
101
+ m.message_id = MessageId.synthetic(domain: sender_domain).value
102
+ m.date = Time.current
103
+ m.in_reply_to = parent_id if parent_id
104
+ m.references = reference_values if reference_values.any?
105
+
106
+ assign_content(m)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ # Set the body on +mail+: a single +text/plain+ part when there are no attachments, otherwise a
113
+ # multipart message whose first part is the plain-text body and whose remaining parts are the
114
+ # attachments. Forcing a top-level +text/plain+ content type would break the multipart case, so
115
+ # it is only set when single-part.
116
+ #
117
+ # @param mail [::Mail::Message] the message under construction
118
+ # @return [void]
119
+ def assign_content(mail)
120
+ if @attachments.empty?
121
+ mail.body = @body
122
+ mail.content_type = 'text/plain; charset=UTF-8'
123
+ return
124
+ end
125
+
126
+ text = ::Mail::Part.new
127
+ text.content_type = 'text/plain; charset=UTF-8'
128
+ text.body = @body
129
+ mail.text_part = text
130
+
131
+ @attachments.each do |attachment|
132
+ mail.attachments[attachment.filename] = {
133
+ mime_type: attachment.content_type,
134
+ content: attachment.bytes
135
+ }
136
+ end
137
+ end
138
+
139
+ # The domain for the outbound Message-ID — the +From+ address's domain, so the id carries the
140
+ # real sending domain (recipients like Gmail distrust a bogus domain and won't thread). A
141
+ # persona (or the console) always sends from a full address, so a From with no domain is a
142
+ # caller bug rather than something to paper over.
143
+ #
144
+ # @return [String] the sending domain.
145
+ # @raise [Protege::ContractViolationError] when the From address has no parseable domain.
146
+ def sender_domain
147
+ @from.to_s[/@(.+)\z/, 1]&.strip.presence ||
148
+ raise(Protege::ContractViolationError,
149
+ "cannot derive a sender domain from From address #{@from.inspect} — it has no domain")
150
+ end
151
+
152
+ # The normalized parent message id, or nil when no reply target was given.
153
+ #
154
+ # @return [String, nil] the angle-bracketed id.
155
+ def parent_id
156
+ return nil if @in_reply_to.nil?
157
+
158
+ MessageId.parse(@in_reply_to).value
159
+ end
160
+
161
+ # The normalized reference chain as angle-bracketed id strings.
162
+ #
163
+ # @return [Array<String>] the chain, possibly empty.
164
+ def reference_values
165
+ @reference_values ||= reference_chain.to_a.map(&:to_s)
166
+ end
167
+
168
+ # Coerce the +references+ input into a +References+ chain.
169
+ #
170
+ # @return [References] the normalized chain.
171
+ def reference_chain
172
+ case @references
173
+ when References then @references
174
+ when String then References.parse(@references.split)
175
+ else References.parse(Array(@references))
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Gateway
5
+ module Mail
6
+ # Value object representing an ordered chain of Message-IDs from the
7
+ # +References+ and +In-Reply-To+ headers of an email thread.
8
+ #
9
+ # Instances are frozen and Enumerable. Construct via +.parse+; build
10
+ # reply chains with +#with_parent_appended+ rather than mutating.
11
+ class References
12
+ include Enumerable
13
+
14
+ class << self
15
+ # Parse a list of raw header values into a +References+ chain.
16
+ #
17
+ # +nil+ entries and blank/whitespace-only strings are dropped; surviving entries are
18
+ # normalized via +MessageId.parse+. Duplicates are preserved.
19
+ #
20
+ # @param items [Array<String, nil>] raw message-id strings from +References+/+In-Reply-To+.
21
+ # @return [References] the normalized chain.
22
+ def parse(items)
23
+ parsed = items.filter_map do |item|
24
+ next if item.nil?
25
+
26
+ stripped = item.to_s.strip
27
+ next if stripped.empty?
28
+
29
+ MessageId.parse(stripped)
30
+ end
31
+ new(parsed)
32
+ end
33
+ end
34
+
35
+ # Wrap a pre-parsed array of +MessageId+s and freeze. Prefer +.parse+.
36
+ #
37
+ # @param message_ids [Array<MessageId>] the ordered chain entries.
38
+ def initialize(message_ids)
39
+ @items = message_ids.dup.freeze
40
+ freeze
41
+ end
42
+
43
+ # Yield each +MessageId+ in order.
44
+ #
45
+ # @yield [message_id] each entry in chain order.
46
+ # @yieldparam message_id [MessageId] the current entry.
47
+ # @return [Enumerator, References] an enumerator without a block, else self.
48
+ def each(&)
49
+ @items.each(&)
50
+ end
51
+
52
+ # Return the first +MessageId+ in the chain.
53
+ #
54
+ # @return [MessageId, nil] the first entry, or +nil+ when empty.
55
+ def first
56
+ @items.first
57
+ end
58
+
59
+ # Report whether the chain has no entries.
60
+ #
61
+ # @return [Boolean] +true+ when empty.
62
+ def empty?
63
+ @items.empty?
64
+ end
65
+
66
+ # Return the number of entries.
67
+ #
68
+ # @return [Integer] the chain length.
69
+ def size
70
+ @items.size
71
+ end
72
+
73
+ # Return the underlying entries as a frozen array.
74
+ #
75
+ # @return [Array<MessageId>] the chain contents.
76
+ def to_a
77
+ @items
78
+ end
79
+
80
+ # Return a new +References+ with +parent+ appended (immutable update).
81
+ #
82
+ # @param parent [String, MessageId] the parent id; Strings are parsed, +MessageId+s reused.
83
+ # @return [References] a new chain with +parent+ at the end.
84
+ def with_parent_appended(parent)
85
+ message_id = parent.is_a?(MessageId) ? parent : MessageId.parse(parent)
86
+ self.class.new(@items + [message_id])
87
+ end
88
+
89
+ # Compare by element-wise value equality.
90
+ #
91
+ # @param other [Object] the value to compare against.
92
+ # @return [Boolean] +true+ when +other+ is a +References+ with equal entries.
93
+ def ==(other)
94
+ other.is_a?(References) && other.to_a == @items
95
+ end
96
+ alias eql? ==
97
+
98
+ # Hash by the underlying array contents.
99
+ #
100
+ # @return [Integer] the hash code.
101
+ def hash
102
+ @items.hash
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+
5
+ module Protege
6
+ # Namespace for all inbound/outbound email concerns — mail parsing,
7
+ # address handling, delivery transport.
8
+ #
9
+ # Module methods here are the cross-namespace entry points. Other namespaces (Orchestrator, Loop) and
10
+ # the Rails layer call these rather than reaching into the +Mail::*+ value objects or +AccessControl+.
11
+ module Gateway
12
+ # Native Ruby autoloads for the Gateway's value types and composer. Unlike Zeitwerk (which isn't set
13
+ # up yet when host +config/initializers+ run), plain +autoload+ resolves these on first reference at
14
+ # any time — so +Gateway.build_access_policy+ works in a boot-time initializer without eager-loading
15
+ # the files. Zeitwerk still manages the same paths for ordinary runtime/reloads.
16
+ autoload :AccessPolicy, 'protege/gateway/access_policy'
17
+ autoload :AccessControl, 'protege/gateway/access_control'
18
+ autoload :AttachmentPolicy, 'protege/gateway/attachment_policy'
19
+
20
+ extend self
21
+
22
+ # Accept a message typed into the dashboard console.
23
+ #
24
+ # Finds or creates the +EmailThread+ for +mail+, persists an inbound +Message+ marked
25
+ # +:pending+, and enqueues a +ConsoleInferenceJob+ (delayed one second so the record is
26
+ # committed and visible before the job runs). The +mail.message_id+ is propagated as the
27
+ # correlation id so events across the run can be traced.
28
+ #
29
+ # @param mail [::Mail::Message] the console-authored message.
30
+ # @param persona [Persona] the persona receiving the message.
31
+ # @param attachments [Array<ActiveStorage::Blob, ActiveStorage::Attachment, #original_filename, Hash>]
32
+ # files for the persona to read. Each entry is normalized to a blob (see {#blob_for}) and attached by
33
+ # reference (mirroring +deliver+) — an already-stored blob/attachment reuses its blob, an uploaded
34
+ # file or a +{ filename:, content_type:, bytes: }+ Hash is uploaded once. Attached after +save!+ so
35
+ # they are committed before the (delayed) job reads the message; the persona sees them via
36
+ # +Message#content_parts+ / the +read_attachment+ tool.
37
+ # @return [Message] the saved inbound message record.
38
+ def accept_console_message(mail:, persona:, attachments: [])
39
+ correlation_id = Mail::MessageId.ensure(mail.message_id).value
40
+ thread = EmailThread.find_or_create_for(mail:, persona:)
41
+ message = Message.build_from_mail(mail:, direction: :inbound, thread:, persona:)
42
+ message.processing_status = :pending
43
+ message.save!
44
+
45
+ # Drop blank entries before coercing: a console file field submits a stray "" when nothing is
46
+ # chosen (an empty multipart part Rack parses as an empty string). Filtering here — at the shared
47
+ # array intake — keeps every caller (reply, new thread, host Gateway.message) clean while blob_for
48
+ # stays strict for genuinely-unsupported values.
49
+ files = attachments.compact_blank
50
+ message.attachments.attach(files.map { blob_for(_1) }) if files.any?
51
+
52
+ ConsoleInferenceJob
53
+ .set(wait: 1.second)
54
+ .perform_later(message_id: message.id,
55
+ persona_id: persona.id,
56
+ correlation_id:)
57
+
58
+ message
59
+ end
60
+
61
+ # Send a message to a persona from the host application — the reverse of tools/resolvers.
62
+ #
63
+ # Where tools and resolvers let the agent reach *into* the app, this lets ordinary application code
64
+ # (a service object, controller, or state machine) hand the agent something to act on:
65
+ #
66
+ # Protege::Gateway.message(to: 'security@acme.com', subject: 'Large transfer flagged',
67
+ # body: 'Review activity for user #678; alert ops@ if suspicious.')
68
+ #
69
+ # It is a thin convenience over +build_outbound+ + +accept_console_message+ — the exact sequence the
70
+ # dashboard console uses — so the brief flows through the same "inbound message starts a thread" path
71
+ # (+ReplyHarness+ and the persona's +message_resolvers+). +to+ is an address, not a record: it is
72
+ # resolved through +Persona.lookup+ (honouring case and +tag+ subaddressing), so a caller can pass a
73
+ # literal address or +persona.email_address+ without loading the record first.
74
+ #
75
+ # Fire-and-forget: it returns the persisted inbound +Message+ for reference/tracing, not an LLM
76
+ # response — the agent's actions (any mail it chooses to send) are the outcome. The persona reads it,
77
+ # reasons, and uses its (scoped) tools; there is nothing it must reply to.
78
+ #
79
+ # Attachments meet the host where its file already is — a stored blob/attachment (e.g.
80
+ # +transaction.receipt+, reused by reference), an +ActionDispatch::Http::UploadedFile+ (a controller
81
+ # upload, passed straight through), or a plain +{ filename:, content_type:, bytes: }+ Hash (raw/
82
+ # generated bytes, uploaded once). So a caller never constructs a +Gateway::Mail::Attachment+ by hand.
83
+ # See {#blob_for} for the full set of accepted shapes.
84
+ #
85
+ # @param to [String] the recipient persona's address (or +persona.email_address+).
86
+ # @param subject [String, nil] the message subject (the thread's title).
87
+ # @param body [String] the brief the persona acts on.
88
+ # @param attachments [Array] files for the persona to read; see {#accept_console_message} / {#blob_for}.
89
+ # @return [Message] the saved inbound message record.
90
+ # @raise [ArgumentError] when no active persona owns +to+, or an attachment is an unsupported shape.
91
+ def message(to:, body:, subject: nil, attachments: [])
92
+ persona = Persona.lookup(to)
93
+ raise ArgumentError, "no active persona for address: #{to.inspect}" unless persona
94
+
95
+ mail = build_outbound(from: Protege.configuration.console_address,
96
+ to: persona.email_address,
97
+ subject:,
98
+ body:)
99
+
100
+ accept_console_message(mail:, persona:, attachments:)
101
+ end
102
+
103
+ # Accept an inbound email arriving via SMTP / Action Mailbox.
104
+ #
105
+ # Unlike the console path, ingestion (persisting the +Message+, building the thread) happens
106
+ # inside +InferenceJob+; this method only enqueues that job with the persona and the inbound
107
+ # email reference, carrying the mail's message id as the correlation id.
108
+ #
109
+ # @param inbound_email [ActionMailbox::InboundEmail] the ingress email record.
110
+ # @param persona [Persona] the persona the message was routed to.
111
+ # @return [void]
112
+ def accept_smtp_message(inbound_email:, persona:)
113
+ InferenceJob.perform_later(
114
+ persona_id: persona.id,
115
+ inbound_email_id: inbound_email.id,
116
+ correlation_id: Mail::MessageId.ensure(inbound_email.mail.message_id).value
117
+ )
118
+ end
119
+
120
+ # Deliver an outbound message on behalf of a persona and record it.
121
+ #
122
+ # Sends over SMTP via +mail.deliver!+ unless the recipient is the local console address, in
123
+ # which case the conversation stays entirely in-app. Either way the message is persisted as an
124
+ # +:outbound+ +Message+ on its thread with +delivered_at+ stamped to now.
125
+ #
126
+ # Attachments are persisted by *reference*: the outbound record does not rebuild them from the
127
+ # mail's MIME parts (which would mint a duplicate blob) — instead the caller passes the +attachments+
128
+ # (blobs already in storage) and they are attached directly. So a file, wherever it came from, is
129
+ # uploaded once and thereafter only referenced.
130
+ #
131
+ # @param mail [::Mail::Message] the outbound mail to deliver.
132
+ # @param persona [Persona] the sending persona.
133
+ # @param attachments [Array<ActiveStorage::Blob>] blobs to attach to the outbound record by reference.
134
+ # @return [Message] the saved outbound message record.
135
+ def deliver(mail:, persona:, attachments: [])
136
+ deliver_over_transport(mail) unless local_delivery?(mail)
137
+
138
+ thread = EmailThread.find_or_create_for(mail:, persona:)
139
+
140
+ Message.build_from_mail(
141
+ mail:,
142
+ thread:,
143
+ persona:,
144
+ direction: :outbound,
145
+ attach_from_mail: false
146
+ ).tap do |record|
147
+ record.delivered_at = Time.current
148
+ record.attachments.attach(attachments) if attachments.any?
149
+ record.save!
150
+ end
151
+ end
152
+
153
+ # Build an outbound +::Mail::Message+ with Protege's threading/id/date conventions. The single
154
+ # place callers (controllers, the send-email tool) assemble outbound mail without touching the
155
+ # +Mail::Outbound+ builder directly. Keyword arguments are forwarded to the builder (+from:+, +to:+,
156
+ # +subject:+, +body:+, +cc:+, +bcc:+, +in_reply_to:+, +references:+, +attachments:+).
157
+ #
158
+ # @return [::Mail::Message] the composed outbound mail
159
+ def build_outbound(**)
160
+ Mail::Outbound.build(**)
161
+ end
162
+
163
+ # Build an inbound access policy — one layer of the sender-authorization guardrail. The public way
164
+ # to construct an +AccessPolicy+, whether the global ceiling (+config.inbound_access+) or a
165
+ # per-persona layer (+AccessRule.policy_for+). +build_+ marks it as an instance factory, not a
166
+ # persisted record. Keyword arguments are forwarded (+allow:+, +deny:+, +default_decision:+).
167
+ #
168
+ # @return [Protege::Gateway::AccessPolicy] the composed policy layer
169
+ def build_access_policy(**)
170
+ AccessPolicy.new(**)
171
+ end
172
+
173
+ # Build an attachment policy — the inbound/outbound attachment limits enforced at the mail edges.
174
+ # The public way to construct an +AttachmentPolicy+ (assigned as +config.attachment_policy+).
175
+ # +build_+ marks it as an instance factory, not a persisted record. Keyword arguments are forwarded
176
+ # (+enabled:+, +max_bytes:+, +max_count:+, +max_total_bytes:+).
177
+ #
178
+ # @return [Protege::Gateway::AttachmentPolicy] the attachment limits policy
179
+ def build_attachment_policy(**)
180
+ AttachmentPolicy.new(**)
181
+ end
182
+
183
+ # Normalize a raw Message-ID header to its canonical, angle-bracketed value.
184
+ #
185
+ # @param raw [String, nil] the raw Message-ID (or In-Reply-To) value
186
+ # @return [String] the canonical id
187
+ def canonical_message_id(raw)
188
+ Mail::MessageId.ensure(raw).value
189
+ end
190
+
191
+ # Parse a single Message-ID header to its canonical value (nil-safe on blank input).
192
+ #
193
+ # @param raw [String, nil] the raw header value
194
+ # @return [String, nil] the canonical id, or nil when blank
195
+ def parse_message_id(raw)
196
+ return nil if raw.blank?
197
+
198
+ Mail::MessageId.parse(raw).value
199
+ end
200
+
201
+ # Parse a References / In-Reply-To chain into an ordered list of canonical id strings.
202
+ #
203
+ # @param items [Array<String>, String, nil] the raw header value(s)
204
+ # @return [Array<String>] the ordered canonical ids
205
+ def reference_ids(items)
206
+ Mail::References.parse(Array(items)).map(&:to_s)
207
+ end
208
+
209
+ # The routing key (tag-stripped, lowercased +local@domain+) for an inbound recipient address.
210
+ #
211
+ # @param address [String, nil] the raw email address
212
+ # @return [String] the routing key
213
+ # @raise [ArgumentError] when the address is blank or malformed
214
+ def routing_key(address)
215
+ Mail::Address.parse(address).routing_key
216
+ end
217
+
218
+ # Report whether a sender may reach a persona, per the persona's inbound access guardrail.
219
+ #
220
+ # @param persona [Persona] the routed persona
221
+ # @param address [String, nil] the sender's address
222
+ # @return [Boolean] true when every access layer permits the sender
223
+ def permits?(persona:, address:)
224
+ AccessControl.for(persona:).permits?(address:)
225
+ end
226
+
227
+ # Report whether an inbound email is addressed to a domain Protege receives for — the Action
228
+ # Mailbox routing predicate. Domain-scoped on purpose: the engine claims only mail for its own
229
+ # domains and leaves everything else to the host's own mailbox routing, so mounting Protege never
230
+ # hijacks a host's other inbound mail. Per-persona/subaddress validation stays in +AgentMailbox+.
231
+ #
232
+ # @param inbound_email [ActionMailbox::InboundEmail] the received email
233
+ # @return [Boolean] true when any recipient's domain is one of ours
234
+ def claims?(inbound_email:)
235
+ Array(inbound_email.mail.to).any? { |address| EmailDomain.receives?(address) }
236
+ end
237
+
238
+ # ── MTA provisioning (push) ────────────────────────────────────────────────
239
+
240
+ # The full set of signing material the self-hosted MTA needs — every +EmailDomain+ with its DKIM
241
+ # key. The app pushes this whole manifest to the mail accessory on any domain change (see
242
+ # {#push_mail_provisioning}); the accessory rewrites its OpenDKIM/Postfix config from it, so the
243
+ # manifest is always complete state, never a delta.
244
+ #
245
+ # @return [Hash] +{ domains: [...] }+, one entry per domain (see +EmailDomain#to_provisioning+)
246
+ def mail_provisioning_manifest
247
+ { domains: EmailDomain.all.map(&:to_provisioning) }
248
+ end
249
+
250
+ # The self-hosted mail accessory's network name, or nil. Reuses the *existing* setting that already
251
+ # names the accessory for outbound SMTP (+config.action_mailer.smtp_settings[:address]+ =
252
+ # +ENV['MAIL_RELAY_HOST']+) — no separate config knob. Its presence is also the self-hosted signal:
253
+ # unset means provider mode, where there is no MTA to provision.
254
+ #
255
+ # @return [String, nil] the accessory host, or nil when not self-hosting
256
+ def mail_accessory_host
257
+ ENV['MAIL_RELAY_HOST'].presence
258
+ end
259
+
260
+ # Push the whole provisioning manifest to the mail accessory's internal control listener, which
261
+ # writes the DKIM keys + Postfix tables and reloads. Event-driven (called from
262
+ # +MailProvisioningJob+ on any +EmailDomain+ change, and by the console "Sync now" button) — no
263
+ # polling, no public endpoint. Authenticates with the already-shared +RAILS_INBOUND_EMAIL_PASSWORD+.
264
+ #
265
+ # A no-op in provider mode (no accessory host). Never raises: a domain save or a button click must
266
+ # not break because the MTA is momentarily down — failures are logged and reported in the result
267
+ # (the job's retry, or the button, re-pushes).
268
+ #
269
+ # @return [Hash] +{ ok:, count:, message: }+ describing the outcome
270
+ def push_mail_provisioning
271
+ host = mail_accessory_host
272
+ return provisioning_result(success: false, count: 0, message: 'No mail accessory configured') if host.blank?
273
+
274
+ manifest = mail_provisioning_manifest
275
+ deliver_provisioning(host:, manifest:)
276
+ count = manifest[:domains].size
277
+ provisioning_result(success: true, count:, message: "Pushed #{count} domain(s) to the mail server")
278
+ rescue StandardError => e
279
+ Protege.configuration.logger.warn("[protege] mail provisioning push failed: #{e.class}: #{e.message}")
280
+ provisioning_result(success: false, count: 0, message: "Mail server unreachable: #{e.message}")
281
+ end
282
+
283
+ private
284
+
285
+ # Coerce one caller-supplied attachment into an +ActiveStorage::Blob+ to attach by reference. A blob
286
+ # passes through; an attachment yields its blob (both reuse storage, no new upload); a
287
+ # +{ filename:, content_type:, bytes: }+ Hash and an uploaded file (anything responding to
288
+ # +original_filename+, i.e. an +ActionDispatch::Http::UploadedFile+) are each uploaded once. Any other
289
+ # shape is a caller error.
290
+ #
291
+ # @param attachment [ActiveStorage::Blob, ActiveStorage::Attachment, ActionDispatch::Http::UploadedFile, Hash]
292
+ # @return [ActiveStorage::Blob] the blob to attach
293
+ # @raise [ArgumentError] on an unsupported shape
294
+ def blob_for(attachment)
295
+ case attachment
296
+ when ActiveStorage::Blob then attachment
297
+ when ActiveStorage::Attachment then attachment.blob
298
+ when Hash then upload_blob(**attachment.symbolize_keys)
299
+ else uploaded_file_blob(attachment)
300
+ end
301
+ end
302
+
303
+ # Upload an +ActionDispatch::Http::UploadedFile+ (or any object exposing +original_filename+/
304
+ # +content_type+/+read+ — e.g. +Rack::Test::UploadedFile+) as a new blob. Duck-typed rather than
305
+ # matched by class so a controller can hand +params[...]+ uploads straight through.
306
+ #
307
+ # @param file [#original_filename, #content_type, #read] the uploaded file
308
+ # @return [ActiveStorage::Blob] the created blob
309
+ # @raise [ArgumentError] when +file+ is not an uploaded-file-like object
310
+ def uploaded_file_blob(file)
311
+ unless file.respond_to?(:original_filename)
312
+ raise ArgumentError,
313
+ "unsupported attachment #{file.class}: pass a blob, an attachment, an uploaded file, or " \
314
+ '{ filename:, content_type:, bytes: }'
315
+ end
316
+
317
+ upload_blob(filename: file.original_filename, content_type: file.content_type, bytes: file.read)
318
+ end
319
+
320
+ # Upload raw bytes as a new blob for a host-supplied +{ filename:, content_type:, bytes: }+ attachment.
321
+ #
322
+ # @param filename [String] the file name
323
+ # @param bytes [String] the raw file contents
324
+ # @param content_type [String, nil] the MIME type (Active Storage infers when omitted)
325
+ # @return [ActiveStorage::Blob] the created blob
326
+ def upload_blob(filename:, bytes:, content_type: nil)
327
+ ActiveStorage::Blob.create_and_upload!(io: StringIO.new(bytes), filename:, content_type:)
328
+ end
329
+
330
+ # POST the manifest JSON to the accessory's control listener over the internal network.
331
+ #
332
+ # @param host [String] the accessory network name
333
+ # @param manifest [Hash] the provisioning manifest to send
334
+ # @return [void]
335
+ # @raise [StandardError] on a transport error or non-2xx response (caught by the caller)
336
+ def deliver_provisioning(host:, manifest:)
337
+ uri = URI("http://#{host}:#{mail_provision_port}/provision")
338
+ request = Net::HTTP::Post.new(uri)
339
+ request.body = manifest.to_json
340
+ request['Content-Type'] = 'application/json'
341
+ request.basic_auth('provision', ENV.fetch('RAILS_INBOUND_EMAIL_PASSWORD', ''))
342
+
343
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
344
+ response = http.request(request)
345
+ raise "accessory returned HTTP #{response.code}" unless response.code.to_i.between?(200, 299)
346
+ end
347
+ end
348
+
349
+ # The accessory's internal control port. A constant (env-overridable) matched by the mail image's
350
+ # +provision.py+ listener — deliberately not a +Configuration+ option.
351
+ #
352
+ # @return [Integer] the control port (default 8025)
353
+ def mail_provision_port
354
+ ENV.fetch('MAIL_PROVISION_PORT', 8025).to_i
355
+ end
356
+
357
+ # Build the small outcome record the console flashes and callers branch on.
358
+ #
359
+ # @param success [Boolean] whether the push reached the accessory
360
+ # @return [Hash] +{ ok:, count:, message: }+
361
+ # @param [Object] count
362
+ # @param [Object] message
363
+ def provisioning_result(success:, count:, message:)
364
+ { ok: success, count:, message: }
365
+ end
366
+
367
+ # Send a raw outbound +Mail::Message+ over Action Mailer's configured transport.
368
+ #
369
+ # The Gateway builds bare +Mail::Message+ objects (via +Gateway::Mail::Outbound+), and
370
+ # +Mail::Message#deliver!+ on its own ignores +config.action_mailer.smtp_settings+ — it falls
371
+ # back to the Mail gem's default of SMTP to +localhost:25+. +wrap_delivery_behavior+ is the same
372
+ # hook Action Mailer applies to its own messages: it sets the delivery method, settings,
373
+ # +perform_deliveries+, and +raise_delivery_errors+ from +ActionMailer::Base+, so the message
374
+ # goes to the real SMTP host in production and to the +:test+ collector under test.
375
+ #
376
+ # @param mail [::Mail::Message] the outbound mail to send
377
+ # @return [void]
378
+ def deliver_over_transport(mail)
379
+ ActionMailer::Base.wrap_delivery_behavior(mail)
380
+ mail.deliver!
381
+ end
382
+
383
+ # Report whether +mail+ is addressed to the local console (no SMTP needed).
384
+ #
385
+ # Compares the first recipient's routing key (tag-stripped, lowercased +local@domain+, via
386
+ # +Mail::Address+) against the configured console address. A nil/malformed recipient is treated
387
+ # as non-local so it falls through to ordinary SMTP delivery.
388
+ #
389
+ # @param mail [::Mail::Message] the outbound mail under inspection.
390
+ # @return [Boolean] +true+ when the recipient is the console address.
391
+ def local_delivery?(mail)
392
+ Mail::Address.parse(Array(mail.to).first).routing_key ==
393
+ Mail::Address.parse(Protege.configuration.console_address).routing_key
394
+ rescue ArgumentError
395
+ false
396
+ end
397
+ end
398
+ end