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.
- checksums.yaml +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- 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
|