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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Gateway
|
|
5
|
+
# The inbound access guardrail: composes every access-policy *layer* that applies to a persona and
|
|
6
|
+
# answers the one question the Gateway asks — may this sender reach this persona? A gateway concern
|
|
7
|
+
# (inbound mail authorization), it lives beside the other Gateway protocol pieces and is called from
|
|
8
|
+
# the Gateway's inbound edge, +AgentMailbox+.
|
|
9
|
+
#
|
|
10
|
+
# Composition is by **intersection**: a sender is permitted only when *every* layer permits it
|
|
11
|
+
# (logical AND). Because intersection is commutative there is no precedence to reason about — the
|
|
12
|
+
# order of layers never changes the outcome — and the security property falls out for free: a layer
|
|
13
|
+
# can only *narrow* the set of admitted senders, never widen it. So a per-persona rule can tighten
|
|
14
|
+
# who reaches that agent but can never punch through the committed org-wide ceiling.
|
|
15
|
+
#
|
|
16
|
+
# Two layers exist today — the committed global policy (+Protege.configuration.inbound_access+) and
|
|
17
|
+
# the persona's runtime rules (+AccessRule.policy_for+). This composer is the single seam the rest of
|
|
18
|
+
# the engine calls; adding a layer later (e.g. a per-persona-class committed policy) is a one-line
|
|
19
|
+
# change to {#policies}, never a change at the call sites.
|
|
20
|
+
#
|
|
21
|
+
# @example Gate an inbound sender
|
|
22
|
+
# Gateway::AccessControl.for(persona:).permits?(address: mail.from.first)
|
|
23
|
+
class AccessControl
|
|
24
|
+
# @return [Protege::Persona] the persona the inbound mail is addressed to
|
|
25
|
+
attr_reader :persona
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Build the access control for a persona. Factory mirror of +.new+ reading as a lookup.
|
|
29
|
+
#
|
|
30
|
+
# @param persona [Protege::Persona] the routed persona
|
|
31
|
+
# @return [Protege::Gateway::AccessControl] the composed guardrail
|
|
32
|
+
def for(persona:)
|
|
33
|
+
new(persona:)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param persona [Protege::Persona] the routed persona
|
|
38
|
+
# @return [void]
|
|
39
|
+
def initialize(persona:)
|
|
40
|
+
@persona = persona
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Decide whether a sender may reach this persona, intersecting all applicable layers.
|
|
44
|
+
#
|
|
45
|
+
# @param address [String] the raw sender address
|
|
46
|
+
# @return [Boolean] true only when every layer permits the sender
|
|
47
|
+
def permits?(address:)
|
|
48
|
+
policies.all? { |policy| policy.permits?(address:) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# The ordered (order-immaterial) list of policy layers that apply to this persona.
|
|
54
|
+
#
|
|
55
|
+
# Add a layer here — and only here — to extend the guardrail.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Protege::Gateway::AccessPolicy>] the global ceiling and the persona's runtime policy
|
|
58
|
+
def policies
|
|
59
|
+
[
|
|
60
|
+
Protege.configuration.inbound_access,
|
|
61
|
+
AccessRule.policy_for(persona)
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Gateway
|
|
5
|
+
# One layer of the inbound access-control guardrail: an immutable value that decides whether a given
|
|
6
|
+
# sender address is permitted, given an allow-list and a deny-list of address patterns. A Gateway
|
|
7
|
+
# value type (inbound authorization) — built through the +Gateway.build_access_policy+ factory,
|
|
8
|
+
# composed by +Gateway::AccessControl+, and assigned as the global ceiling via +config.inbound_access+
|
|
9
|
+
# or per-persona by +AccessRule.policy_for+:
|
|
10
|
+
#
|
|
11
|
+
# config.inbound_access = Protege::Gateway.build_access_policy(allow: ['*@company.co'])
|
|
12
|
+
#
|
|
13
|
+
# A policy is evaluated in a fixed precedence — an explicit +deny+ match always wins, then an
|
|
14
|
+
# +allow+ match admits, then the +default_decision+ settles everyone else. The default is *derived*
|
|
15
|
+
# when not given: the mere presence of an allow rule flips the layer into allow-list mode
|
|
16
|
+
# (default-deny), which is the intuitive reading of "only these may enter". With no allow rules the
|
|
17
|
+
# default is +:allow+, so a layer carrying only deny rules behaves as a block-list.
|
|
18
|
+
#
|
|
19
|
+
# Patterns are matched against the sender's tag-stripped, lowercased +local@domain+ routing key (so
|
|
20
|
+
# +ceo+finance@Company.CO+ matches a +ceo@company.co+ rule), and support a single +*+ wildcard
|
|
21
|
+
# (e.g. +*@company.co+). This object knows nothing about layering; composing several policies into
|
|
22
|
+
# the org→persona guardrail is +AccessControl+'s job.
|
|
23
|
+
#
|
|
24
|
+
# @example The identity policy — a bare +build_access_policy+ permits everyone (empty lists derive a
|
|
25
|
+
# default-allow), so it drops out of the +AccessControl+ intersection as a no-op.
|
|
26
|
+
AccessPolicy = Data.define(:allow, :deny, :default_decision) do
|
|
27
|
+
# Build a policy, normalizing the pattern lists and deriving the default decision.
|
|
28
|
+
#
|
|
29
|
+
# @param allow [Array<String>] allow-list patterns (blank/nil entries are dropped)
|
|
30
|
+
# @param deny [Array<String>] deny-list patterns (blank/nil entries are dropped)
|
|
31
|
+
# @param default_decision [Symbol, nil] +:allow+/+:deny+; when +nil+, derived from +allow+
|
|
32
|
+
# (present → +:deny+, empty → +:allow+)
|
|
33
|
+
def initialize(allow: [], deny: [], default_decision: nil)
|
|
34
|
+
allow = normalize_patterns(allow)
|
|
35
|
+
deny = normalize_patterns(deny)
|
|
36
|
+
# Derive the default decision when not given: if any allow rules exist, default-deny;
|
|
37
|
+
# otherwise default-allow.
|
|
38
|
+
default_decision ||= allow.empty? ? :allow : :deny
|
|
39
|
+
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Decide whether a sender address is permitted by this layer.
|
|
44
|
+
#
|
|
45
|
+
# @param address [String] the raw sender address (display name, plus-tag, and case tolerated)
|
|
46
|
+
# @return [Boolean] true when the sender may pass this layer
|
|
47
|
+
def permits?(address:)
|
|
48
|
+
key = routing_key_for(address)
|
|
49
|
+
|
|
50
|
+
return false if deny.any? { |pattern| matches?(pattern:, key:) }
|
|
51
|
+
return true if allow.any? { |pattern| matches?(pattern:, key:) }
|
|
52
|
+
|
|
53
|
+
default_decision == :allow
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Normalize a raw pattern list: stringify, strip, downcase, and drop blanks.
|
|
59
|
+
#
|
|
60
|
+
# @param patterns [Array<String, nil>] the raw patterns
|
|
61
|
+
# @return [Array<String>] the cleaned, lowercased patterns
|
|
62
|
+
def normalize_patterns(patterns)
|
|
63
|
+
Array(patterns).filter_map do |pattern|
|
|
64
|
+
cleaned = pattern.to_s.strip.downcase
|
|
65
|
+
cleaned unless cleaned.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reduce a sender address to its tag-stripped, lowercased routing key.
|
|
70
|
+
#
|
|
71
|
+
# Delegates to +Gateway::Mail::Address+ so subaddressing and RFC 5322 forms line up with the
|
|
72
|
+
# routing used elsewhere; on a malformed address, falls back to a raw downcased comparison so a
|
|
73
|
+
# deny rule can still match obviously-bad input.
|
|
74
|
+
#
|
|
75
|
+
# @param address [String] the raw sender address
|
|
76
|
+
# @return [String] the comparison key
|
|
77
|
+
def routing_key_for(address)
|
|
78
|
+
Mail::Address.parse(address).routing_key
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
address.to_s.strip.downcase
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Test one pattern against one routing key, expanding a single +*+ wildcard.
|
|
84
|
+
#
|
|
85
|
+
# @param pattern [String] the normalized rule (may contain +*+)
|
|
86
|
+
# @param key [String] the normalized sender routing key
|
|
87
|
+
# @return [Boolean] true when the pattern matches the key
|
|
88
|
+
def matches?(pattern:, key:)
|
|
89
|
+
regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\\*', '.*')}\\z")
|
|
90
|
+
regex.match?(key)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Gateway
|
|
5
|
+
# Immutable attachment limits + the policy that enforces them. A Gateway value type consulted at the
|
|
6
|
+
# mail edges — built through the +Gateway.build_attachment_policy+ factory and assigned as
|
|
7
|
+
# +config.attachment_policy+:
|
|
8
|
+
#
|
|
9
|
+
# config.attachment_policy = Protege::Gateway.build_attachment_policy(max_bytes: 5.megabytes)
|
|
10
|
+
#
|
|
11
|
+
# A +Data+ value with the engine's default limits, consulted at the edges: the inbound mailbox
|
|
12
|
+
# bounces a violation, and the +send_email+ tool refuses one. +enabled+ is the master switch — when
|
|
13
|
+
# false, *any* attachment is rejected (inbound mail with attachments bounces; the agent can't attach
|
|
14
|
+
# files), while plain mail is unaffected. When enabled, limits are checked in a fixed order — count,
|
|
15
|
+
# then per-attachment size, then total size — and the first breach wins, so +#violation+ returns a
|
|
16
|
+
# single human-readable reason (or +nil+ when the set is acceptable).
|
|
17
|
+
AttachmentPolicy = Data.define(:enabled, :max_bytes, :max_count, :max_total_bytes) do
|
|
18
|
+
# @param enabled [Boolean] whether attachments are accepted at all (default true)
|
|
19
|
+
# @param max_count [Integer] maximum attachments per message (default 10)
|
|
20
|
+
# @param max_bytes [Integer] maximum bytes per attachment (default 10 MB)
|
|
21
|
+
# @param max_total_bytes [Integer] maximum combined bytes (default 25 MB)
|
|
22
|
+
def initialize(
|
|
23
|
+
enabled: true,
|
|
24
|
+
max_count: 10,
|
|
25
|
+
max_bytes: 10 * 1024 * 1024,
|
|
26
|
+
max_total_bytes: 25 * 1024 * 1024
|
|
27
|
+
)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Report whether the given attachment sizes are acceptable.
|
|
32
|
+
#
|
|
33
|
+
# @param byte_sizes [Array<Integer>] each attachment's size in bytes
|
|
34
|
+
# @return [Boolean] true when the set is acceptable
|
|
35
|
+
def permits?(byte_sizes:)
|
|
36
|
+
violation(byte_sizes:).nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Return the first reason the set is rejected, or +nil+ when acceptable. A message with no
|
|
40
|
+
# attachments is always acceptable, even when attachments are disabled.
|
|
41
|
+
#
|
|
42
|
+
# @param byte_sizes [Array<Integer>] each attachment's size in bytes
|
|
43
|
+
# @return [String, nil] the rejection reason, or nil
|
|
44
|
+
def violation(byte_sizes:)
|
|
45
|
+
sizes = Array(byte_sizes)
|
|
46
|
+
|
|
47
|
+
return nil if sizes.empty?
|
|
48
|
+
|
|
49
|
+
%i[
|
|
50
|
+
attachments_disabled?
|
|
51
|
+
too_many_attachments?
|
|
52
|
+
any_attachment_too_large?
|
|
53
|
+
total_size_exceeded?
|
|
54
|
+
].each do |validator|
|
|
55
|
+
if (reason = send(validator, sizes))
|
|
56
|
+
return reason
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Render a byte count as a rounded megabytes label for limit messages.
|
|
66
|
+
#
|
|
67
|
+
# @param bytes [Integer] the byte count
|
|
68
|
+
# @return [String] e.g. +"10 MB"+
|
|
69
|
+
def megabytes(bytes)
|
|
70
|
+
"#{(bytes / (1024.0 * 1024)).round} MB"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Determines if attachments are enabled at all.
|
|
74
|
+
#
|
|
75
|
+
# @param _sizes [Array<Integer>] each attachment's size in bytes (ignored)
|
|
76
|
+
# @return [String, nil] a rejection reason when disabled, or +nil+ when enabled
|
|
77
|
+
# @param [Object] _sizes
|
|
78
|
+
def attachments_disabled?(_sizes)
|
|
79
|
+
return false if enabled
|
|
80
|
+
|
|
81
|
+
'attachments are not accepted'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Determines if there are too many attachments, based on the +max_count+ limit.
|
|
85
|
+
#
|
|
86
|
+
# @param sizes [Array<Integer>] each attachment's size in bytes
|
|
87
|
+
# @return [String, nil] a rejection reason when too many, or +nil+ when acceptable
|
|
88
|
+
def too_many_attachments?(sizes)
|
|
89
|
+
return false if sizes.size <= max_count
|
|
90
|
+
|
|
91
|
+
"too many attachments (max #{max_count})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Determines if any attachment exceeds the +max_bytes+ limit.
|
|
95
|
+
#
|
|
96
|
+
# @param sizes [Array<Integer>] each attachment's size in bytes
|
|
97
|
+
# @return [String, nil] a rejection reason when any attachment is too large, or +nil+ when acceptable
|
|
98
|
+
def any_attachment_too_large?(sizes)
|
|
99
|
+
return false if sizes.none? { _1 > max_bytes }
|
|
100
|
+
|
|
101
|
+
"an attachment exceeds the #{megabytes(max_bytes)} limit"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Determines if the total of all attachments exceeds the +max_total_bytes+ limit.
|
|
105
|
+
#
|
|
106
|
+
# @param sizes [Array<Integer>] each attachment's size in bytes
|
|
107
|
+
# @return [String, nil] a rejection reason when the total size is exceeded, or +nil+ when acceptable
|
|
108
|
+
def total_size_exceeded?(sizes)
|
|
109
|
+
return false if sizes.sum <= max_total_bytes
|
|
110
|
+
|
|
111
|
+
"attachments exceed the #{megabytes(max_total_bytes)} total limit"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mail'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
module Gateway
|
|
7
|
+
# Namespace for email-specific value objects — addresses, Message-IDs,
|
|
8
|
+
# and References chains — used when parsing inbound mail and building
|
|
9
|
+
# threaded replies. Pure lexical helpers with no transport concerns.
|
|
10
|
+
module Mail
|
|
11
|
+
# Value object representing an email address with optional plus-tag
|
|
12
|
+
# and optional display name.
|
|
13
|
+
#
|
|
14
|
+
# Instances are frozen. Local part, tag, and domain are lowercased.
|
|
15
|
+
# Construct via +.parse+, which delegates lexical parsing to
|
|
16
|
+
# +::Mail::Address+ (handling RFC 5322 oddities like quoted display
|
|
17
|
+
# names and comments) and then layers plus-tag splitting and the
|
|
18
|
+
# routing-key concern on top.
|
|
19
|
+
#
|
|
20
|
+
# Raises +ArgumentError+ when input is nil, blank, or has no +@+
|
|
21
|
+
# separator. Display name is excluded from equality — two addresses
|
|
22
|
+
# with the same +local+, +tag+, and +domain+ are equal regardless of
|
|
23
|
+
# the surrounding display name.
|
|
24
|
+
class Address
|
|
25
|
+
# Bare mailbox name with any plus-tag stripped, lowercased.
|
|
26
|
+
attr_reader :local
|
|
27
|
+
|
|
28
|
+
# Plus-tag suffix, or nil if no +tag was present in the source.
|
|
29
|
+
attr_reader :tag
|
|
30
|
+
|
|
31
|
+
# Domain part of the address, lowercased.
|
|
32
|
+
attr_reader :domain
|
|
33
|
+
|
|
34
|
+
# Display name from a +"Name" <addr>+ form, or nil if absent.
|
|
35
|
+
attr_reader :display_name
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Parse a raw address string into a normalized +Address+.
|
|
39
|
+
#
|
|
40
|
+
# Delegates the lexical parse to +::Mail::Address+ so quoted display names, comments, and
|
|
41
|
+
# other RFC 5322 syntax are handled, then splits any plus-tag off the local part and
|
|
42
|
+
# lowercases the local and domain.
|
|
43
|
+
#
|
|
44
|
+
# @param value [String] the raw address (optionally with display name and plus-tag).
|
|
45
|
+
# @return [Address] the normalized, frozen address.
|
|
46
|
+
# @raise [ArgumentError] when +value+ is nil/blank or has no +@+-separated mailbox.
|
|
47
|
+
def parse(value)
|
|
48
|
+
raise ArgumentError, 'address cannot be nil or blank' if value.nil? || value.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
parsed = validate_mailbox(::Mail::Address.new(value.to_s.strip), value)
|
|
51
|
+
local, tag = split_plus_tag(parsed.local.downcase)
|
|
52
|
+
domain = parsed.domain.downcase
|
|
53
|
+
display_name = parsed.display_name
|
|
54
|
+
|
|
55
|
+
new(local:, tag:, domain:, display_name:)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Ensure a parsed mailbox has both a local part and a domain.
|
|
61
|
+
#
|
|
62
|
+
# @param parsed [::Mail::Address] the result of the lexical parse.
|
|
63
|
+
# @param original [String] the original input, used in the error message.
|
|
64
|
+
# @return [::Mail::Address] +parsed+ unchanged when valid.
|
|
65
|
+
# @raise [ArgumentError] when the local part or domain is missing.
|
|
66
|
+
def validate_mailbox(parsed, original)
|
|
67
|
+
return parsed unless parsed.local.nil? ||
|
|
68
|
+
parsed.local.empty? ||
|
|
69
|
+
parsed.domain.nil? ||
|
|
70
|
+
parsed.domain.empty?
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "address missing '@': #{original.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Split a local part into its bare name and optional plus-tag.
|
|
76
|
+
#
|
|
77
|
+
# @param local_with_tag [String] the lowercased local part, possibly containing +'+'+.
|
|
78
|
+
# @return [Array(String, String), Array(String, nil)] the bare local and tag, or
|
|
79
|
+
# +[local, nil]+ when no plus-tag is present.
|
|
80
|
+
def split_plus_tag(local_with_tag)
|
|
81
|
+
plus_index = local_with_tag.index('+')
|
|
82
|
+
return [local_with_tag, nil] if plus_index.nil?
|
|
83
|
+
|
|
84
|
+
[
|
|
85
|
+
local_with_tag[0...plus_index],
|
|
86
|
+
local_with_tag[(plus_index + 1)..]
|
|
87
|
+
]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Wrap pre-parsed address parts and freeze. Prefer +.parse+.
|
|
92
|
+
#
|
|
93
|
+
# Blank display names are coerced to +nil+ so equality and rendering ignore them.
|
|
94
|
+
#
|
|
95
|
+
# @param local [String] the bare mailbox name, lowercased.
|
|
96
|
+
# @param domain [String] the domain, lowercased.
|
|
97
|
+
# @param tag [String, nil] the plus-tag, or +nil+.
|
|
98
|
+
# @param display_name [String, nil] the display name, or +nil+/blank to omit.
|
|
99
|
+
def initialize(local:, domain:, tag: nil, display_name: nil)
|
|
100
|
+
@local = local
|
|
101
|
+
@tag = tag
|
|
102
|
+
@domain = domain
|
|
103
|
+
@display_name = display_name.nil? || display_name.to_s.strip.empty? ? nil : display_name
|
|
104
|
+
|
|
105
|
+
freeze
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Return the tag-stripped +local@domain+ form used for routing.
|
|
109
|
+
#
|
|
110
|
+
# @return [String] the routing key with no plus-tag.
|
|
111
|
+
def routing_key
|
|
112
|
+
"#{@local}@#{@domain}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Return the canonical mailbox form, including the plus-tag when present.
|
|
116
|
+
#
|
|
117
|
+
# The display name is never included.
|
|
118
|
+
#
|
|
119
|
+
# @return [String] +local+tag@domain+ or +local@domain+.
|
|
120
|
+
def to_s
|
|
121
|
+
@tag ? "#{@local}+#{@tag}@#{@domain}" : "#{@local}@#{@domain}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Compare for equality by local, tag, and domain, ignoring display name.
|
|
125
|
+
#
|
|
126
|
+
# @param other [Object] the value to compare against.
|
|
127
|
+
# @return [Boolean] +true+ when +other+ is an +Address+ with matching parts.
|
|
128
|
+
def ==(other)
|
|
129
|
+
other.is_a?(Address) &&
|
|
130
|
+
other.local == @local &&
|
|
131
|
+
other.tag == @tag &&
|
|
132
|
+
other.domain == @domain
|
|
133
|
+
end
|
|
134
|
+
alias eql? ==
|
|
135
|
+
|
|
136
|
+
# Hash by local, tag, and domain so equal addresses hash alike.
|
|
137
|
+
#
|
|
138
|
+
# @return [Integer] the hash code.
|
|
139
|
+
def hash
|
|
140
|
+
[@local, @tag, @domain].hash
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
module Gateway
|
|
5
|
+
module Mail
|
|
6
|
+
# Value object for a file as a mail attachment — the typed payload +Outbound+ turns into a MIME
|
|
7
|
+
# part. It is the one shape the send path speaks: a filename, a content type, and the raw bytes on
|
|
8
|
+
# the wire. Both a reattached stored file (+Protege::StoredFile#to_mail_attachment+) and any
|
|
9
|
+
# freshly produced bytes become one of these, so the payload is defined in a single place rather
|
|
10
|
+
# than passed around as an anonymous Hash (mirroring how +::Mail+ is confined to +Outbound+).
|
|
11
|
+
#
|
|
12
|
+
# These bytes are for *transport* only; the file's durable identity remains its blob, which the
|
|
13
|
+
# outbound message attaches by reference on persistence.
|
|
14
|
+
Attachment = Data.define(:filename, :content_type, :bytes)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Protege
|
|
6
|
+
module Gateway
|
|
7
|
+
module Mail
|
|
8
|
+
# Value object representing an RFC 5322 Message-ID.
|
|
9
|
+
#
|
|
10
|
+
# Instances are frozen on construction; equality is based on the
|
|
11
|
+
# normalized string value. Construct via the factory methods —
|
|
12
|
+
# +.parse+, +.synthetic+, or +.ensure+ — rather than +.new+.
|
|
13
|
+
class MessageId
|
|
14
|
+
# Matches a leading or trailing angle bracket, used to strip stray brackets before re-wrapping.
|
|
15
|
+
EDGE_BRACKETS = /\A<|>\z/
|
|
16
|
+
private_constant :EDGE_BRACKETS
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Parse a raw value into a normalized, angle-bracketed +MessageId+.
|
|
20
|
+
#
|
|
21
|
+
# Trims surrounding whitespace and preserves case. Already-bracketed values pass through;
|
|
22
|
+
# half-bracketed values lose the stray bracket before being re-wrapped.
|
|
23
|
+
#
|
|
24
|
+
# @param value [String] the raw message-id, with or without brackets.
|
|
25
|
+
# @return [MessageId] the normalized message id.
|
|
26
|
+
def parse(value)
|
|
27
|
+
new(normalize(value))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build a unique synthetic +MessageId+ for inputs that lack one.
|
|
31
|
+
#
|
|
32
|
+
# The +domain+ should be the real sending domain for outbound mail, so the Message-ID is
|
|
33
|
+
# well-formed and recipients (e.g. Gmail) trust it and thread replies. It defaults to
|
|
34
|
+
# +protege.local+ only for the inbound/console fallback case, where the id is internal and
|
|
35
|
+
# never travels back out.
|
|
36
|
+
#
|
|
37
|
+
# @param domain [String] the domain for the id's right-hand side.
|
|
38
|
+
# @return [MessageId] a fresh +<synthetic.UUID@domain>+ id.
|
|
39
|
+
def synthetic(domain: 'protege.local')
|
|
40
|
+
new("<synthetic.#{SecureRandom.uuid}@#{domain}>")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Parse +value+, or fall back to a synthetic id when it is missing.
|
|
44
|
+
#
|
|
45
|
+
# @param value [String, nil] the raw message-id, possibly nil or blank.
|
|
46
|
+
# @return [MessageId] the parsed id, or a synthetic one when +value+ is nil/blank.
|
|
47
|
+
def ensure(value)
|
|
48
|
+
return synthetic if value.nil? || value.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
parse(value)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Normalize a raw value to a trimmed, angle-bracketed string.
|
|
56
|
+
#
|
|
57
|
+
# @param value [String] the raw message-id.
|
|
58
|
+
# @return [String] the value wrapped in a single pair of angle brackets.
|
|
59
|
+
def normalize(value)
|
|
60
|
+
trimmed = value.to_s.strip
|
|
61
|
+
return trimmed if trimmed.start_with?('<') && trimmed.end_with?('>')
|
|
62
|
+
|
|
63
|
+
"<#{trimmed.gsub(EDGE_BRACKETS, '')}>"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Normalized message-id string (always angle-bracketed).
|
|
68
|
+
attr_reader :value
|
|
69
|
+
|
|
70
|
+
# Wrap a pre-normalized value and freeze. Prefer the factory methods.
|
|
71
|
+
#
|
|
72
|
+
# @param value [String] an already-normalized, angle-bracketed message-id.
|
|
73
|
+
def initialize(value)
|
|
74
|
+
@value = value
|
|
75
|
+
freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Compare by normalized value.
|
|
79
|
+
#
|
|
80
|
+
# @param other [Object] the value to compare against.
|
|
81
|
+
# @return [Boolean] +true+ when +other+ is a +MessageId+ with the same value.
|
|
82
|
+
def ==(other)
|
|
83
|
+
other.is_a?(MessageId) && @value == other.value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
alias eql? ==
|
|
87
|
+
|
|
88
|
+
# Hash by normalized value so a +MessageId+ works as a Hash key.
|
|
89
|
+
#
|
|
90
|
+
# @return [Integer] the hash code.
|
|
91
|
+
def hash
|
|
92
|
+
@value.hash
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Return the normalized string value.
|
|
96
|
+
#
|
|
97
|
+
# @return [String] the angle-bracketed message-id.
|
|
98
|
+
def to_s
|
|
99
|
+
@value
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|