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,135 @@
|
|
|
1
|
+
# Deploying self-hosted mail (Postfix + OpenDKIM)
|
|
2
|
+
|
|
3
|
+
This is the advanced, no-provider path: a Postfix + OpenDKIM MTA runs on your own host, receiving
|
|
4
|
+
inbound mail on port 25 and delivering outbound directly to recipient MX servers. OpenDKIM signs with
|
|
5
|
+
the key **Protege already generates** (`Protege::EmailDomain#dkim_private_key_pem`) — no second key
|
|
6
|
+
source. The engine is transport-agnostic; nothing in it changes.
|
|
7
|
+
|
|
8
|
+
> Replace `agent.example.com`, `YOUR_VPS_IP`, and `YOUR_REGISTRY` below with your own values.
|
|
9
|
+
|
|
10
|
+
## Why self-hosting mail is hard
|
|
11
|
+
|
|
12
|
+
Running your own MTA means owning deliverability: correct **PTR/reverse DNS**, **SPF/DKIM/DMARC**, an
|
|
13
|
+
**unblocked outbound port 25**, and IP reputation. If you just want it to work, use an email provider
|
|
14
|
+
(see the provider path in the docs) — it's the recommended default. Self-host only if you specifically
|
|
15
|
+
want no relay provider.
|
|
16
|
+
|
|
17
|
+
## 1. Build & publish the image
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
docker build --platform linux/amd64 -t YOUR_REGISTRY/protege-mail:latest deploy/mail
|
|
21
|
+
docker push YOUR_REGISTRY/protege-mail:latest
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`.github/workflows/build-mail-image.example.yml` (also generated) automates this in CI — rename it to
|
|
25
|
+
`build-mail-image.yml` and set your registry.
|
|
26
|
+
|
|
27
|
+
## 2. Run the image as your MTA
|
|
28
|
+
|
|
29
|
+
`kamal-accessory.example.yml` (also generated) is a ready Kamal accessory. Merge its `mail:` block into
|
|
30
|
+
your `config/deploy.yml`, then set the env it references:
|
|
31
|
+
|
|
32
|
+
- **clear:** `MAIL_DOMAIN` (the MTA's own FQDN, e.g. `agent.example.com`), `APP_RELAY_URL`
|
|
33
|
+
(`https://agent.example.com/rails/action_mailbox/relay/inbound_emails`), `DMARC_POLICY`
|
|
34
|
+
(`enforce` — see [Inbound sender authentication](#inbound-sender-authentication-spf-dkim-dmarc))
|
|
35
|
+
- **secret:** `RAILS_INBOUND_EMAIL_PASSWORD` (the only secret)
|
|
36
|
+
|
|
37
|
+
**No DKIM key secret.** The DKIM keys live in the app (`Protege::EmailDomain#dkim_private_key_pem`) and
|
|
38
|
+
are **pushed** to the MTA over the internal Kamal network whenever a domain changes (and via the
|
|
39
|
+
dashboard's *Sync now* button) — one image serves every domain, and adding one needs no rebuild or
|
|
40
|
+
reboot. Persist the pushed keys on the `protege_mail_provision` volume (already in the accessory block) so
|
|
41
|
+
they survive restarts; the container re-applies the cached set on boot.
|
|
42
|
+
|
|
43
|
+
For the app to reach the MTA, set `MAIL_RELAY_HOST: protege-mail` in your **app** service's clear env —
|
|
44
|
+
the same accessory name it already delivers outbound SMTP to. Its presence is also what tells the console
|
|
45
|
+
it's self-hosting (so it shows this domain's own DKIM record and the *Sync now* button).
|
|
46
|
+
|
|
47
|
+
## 3. Point the app at the MTA (`config/environments/production.rb`)
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# Inbound: accept the MTA's relay POST.
|
|
51
|
+
config.action_mailbox.ingress = :relay
|
|
52
|
+
config.action_mailbox.ingress_password = ENV.fetch('RAILS_INBOUND_EMAIL_PASSWORD', nil)
|
|
53
|
+
|
|
54
|
+
# Outbound: hand mail to the MTA (resolvable name on your container network).
|
|
55
|
+
config.action_mailer.delivery_method = :smtp
|
|
56
|
+
config.action_mailer.smtp_settings = {
|
|
57
|
+
address: ENV.fetch('MAIL_RELAY_HOST', 'protege-mail'),
|
|
58
|
+
port: ENV.fetch('MAIL_RELAY_PORT', 25).to_i,
|
|
59
|
+
authentication: nil,
|
|
60
|
+
enable_starttls_auto: false
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Action Mailbox runs **one** ingress at a time — switching to `:relay` replaces any provider ingress.
|
|
65
|
+
|
|
66
|
+
## 4. Infrastructure
|
|
67
|
+
|
|
68
|
+
- **PTR / reverse DNS:** map `YOUR_VPS_IP` → `agent.example.com` in your host's control panel.
|
|
69
|
+
Verify: `dig +short -x YOUR_VPS_IP` returns `agent.example.com.`
|
|
70
|
+
- **Inbound firewall:** allow inbound TCP `25` from `0.0.0.0/0` so the internet can reach your MX.
|
|
71
|
+
- **Outbound port 25:** many providers (e.g. Hetzner) block outbound 25 by default — request an
|
|
72
|
+
unblock. Outbound delivery cannot work until it's lifted; inbound is unaffected.
|
|
73
|
+
|
|
74
|
+
## 5. Domains + DNS records
|
|
75
|
+
|
|
76
|
+
For **each** agent domain, add it in the dashboard (**Domains → New**). That generates its DKIM keypair,
|
|
77
|
+
shows the exact records to publish, and **pushes the key to the MTA automatically** (no reboot). If you
|
|
78
|
+
add domains while the accessory is down, or you reboot it, click **Sync now** on a domain page to re-push
|
|
79
|
+
(the container also re-applies the last-known set on boot). Read the values off the domain's page, or:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
kamal app exec --reuse "bin/rails runner 'd=Protege::EmailDomain.find_or_create_by!(domain: %(agent.example.com)); \
|
|
83
|
+
puts d.dkim_dns_name, d.dkim_txt_record, d.spf_txt_record, d.dmarc_txt_record, d.mx_record'"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Publish per domain (repeat for each):
|
|
87
|
+
|
|
88
|
+
| Type | Host | Value |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `TXT` | `default._domainkey.agent.example.com` | `#dkim_txt_record` |
|
|
91
|
+
| `TXT` | `agent.example.com` | `v=spf1 mx ~all` (`#spf_txt_record`) |
|
|
92
|
+
| `TXT` | `_dmarc.agent.example.com` | `#dmarc_txt_record` |
|
|
93
|
+
| `MX` | `agent.example.com` | `10 agent.example.com.` (`#mx_record`) — change **last**, at cutover |
|
|
94
|
+
|
|
95
|
+
Pre-stage the TXT records anytime. Do **not** change the MX until you cut over (it routes live inbound).
|
|
96
|
+
|
|
97
|
+
## 6. Cutover
|
|
98
|
+
|
|
99
|
+
1. Ensure inbound 25 is open, the TXT records are published, and each domain has been added in the
|
|
100
|
+
dashboard (which pushes its key to the MTA).
|
|
101
|
+
2. Deploy the app and **boot the accessory** (Kamal `deploy` does not manage accessories):
|
|
102
|
+
`kamal accessory boot mail`.
|
|
103
|
+
3. Point the **MX** at `agent.example.com` (priority 10). Inbound now arrives at your box.
|
|
104
|
+
4. Validate: `kamal accessory logs mail`; email the persona from Gmail → reply lands with
|
|
105
|
+
**SPF/DKIM/DMARC = pass** ("Show original"). Score at [mail-tester.com](https://www.mail-tester.com)
|
|
106
|
+
and iterate on reputation.
|
|
107
|
+
|
|
108
|
+
## Inbound sender authentication (SPF / DKIM / DMARC)
|
|
109
|
+
|
|
110
|
+
The MTA authenticates **incoming** mail before it ever reaches the app, so a spoofed `From:` can't slip
|
|
111
|
+
past the engine's inbound access list (which keys off the `From` address). Three checks run at SMTP time:
|
|
112
|
+
|
|
113
|
+
- **policyd-spf** rejects a hard SPF fail (the sender domain's `-all` disavows the sending IP) and records
|
|
114
|
+
the SPF result for DMARC.
|
|
115
|
+
- **OpenDKIM** verifies the DKIM signature.
|
|
116
|
+
- **OpenDMARC** evaluates DMARC alignment against the sender domain's published policy and **rejects** the
|
|
117
|
+
message when that policy is `p=reject` and it fails. Domains publishing `p=none`/`p=quarantine` are not
|
|
118
|
+
rejected (we honor the stated policy); the verdict is still stamped in `Authentication-Results`.
|
|
119
|
+
|
|
120
|
+
Our own outbound (the app container, on RFC1918) skips all three — only remote inbound mail is checked.
|
|
121
|
+
|
|
122
|
+
**The `DMARC_POLICY` toggle** (clear env on the accessory):
|
|
123
|
+
|
|
124
|
+
- `enforce` (default) — reject failing mail at SMTP.
|
|
125
|
+
- `monitor` — evaluate and stamp `Authentication-Results` headers but reject nothing. Use this for a
|
|
126
|
+
first-run window: deploy with `monitor`, watch `kamal accessory logs mail` for `dmarc=fail` on
|
|
127
|
+
legitimate senders, then switch to `enforce` and redeploy (no image rebuild needed).
|
|
128
|
+
|
|
129
|
+
To verify: from a Gmail account, email the persona and confirm a normal message is delivered with
|
|
130
|
+
`dmarc=pass`; a spoofing attempt against a `p=reject` domain is refused at SMTP (visible in the logs).
|
|
131
|
+
|
|
132
|
+
## Rollback
|
|
133
|
+
|
|
134
|
+
Restore the previous inbound ingress + SMTP settings in `production.rb`, restore the MX to your prior
|
|
135
|
+
provider, and redeploy.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Self-hosted mail (Postfix + OpenDKIM + OpenDMARC)
|
|
2
|
+
|
|
3
|
+
These files build a single Docker image that runs **Postfix** (the MTA), **OpenDKIM** (DKIM
|
|
4
|
+
sign/verify), and **OpenDMARC** + **policyd-spf** (inbound SPF/DKIM/DMARC enforcement) — so Protege can
|
|
5
|
+
receive and send mail without a relay provider, and reject spoofed inbound mail at SMTP. One image serves
|
|
6
|
+
**every** agent domain: the DKIM keys and per-domain tables aren't baked in — the Rails app **pushes** the
|
|
7
|
+
current set of domains to the running container whenever you add or remove one (and via the console's
|
|
8
|
+
"Sync now" button), so adding a domain never needs a rebuild or reboot. They were generated by
|
|
9
|
+
`bin/rails g protege:postfix`. Build them into an image, run that image as your MTA (e.g. a Kamal
|
|
10
|
+
accessory), and point your app's mail config at it.
|
|
11
|
+
|
|
12
|
+
See **MAIL.md** in this directory for the full deployment guide (DNS, reverse DNS, firewall, cutover).
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
| File | Purpose |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `Dockerfile` | Builds the image (Debian + `postfix opendkim opendkim-tools opendmarc postfix-policyd-spf-python rsyslog curl`; `python3` for `provision.py` comes in with policyd-spf). |
|
|
19
|
+
| `entrypoint.sh` | Templates the box hostname + DMARC toggle, wires the inbound pipe + SPF policy service, re-applies the cached domain manifest, starts the daemons + the provisioning listener. |
|
|
20
|
+
| `provision.py` | The provisioning listener + apply logic. Receives the domain manifest the app pushes, writes the per-domain DKIM keys + `KeyTable`/`SigningTable`/transport, and reloads. Also runs once at boot from the cached manifest. Internal-only listener (not published); auth via `RAILS_INBOUND_EMAIL_PASSWORD`. |
|
|
21
|
+
| `main.cf` | Postfix config. Accepts mail for the served domains and pipes it to your app; relays outbound directly to recipient MX; runs the SPF policy + DKIM/DMARC milters on inbound. `transport_maps`/`relay_domains` are filled per-domain by `provision.py`. |
|
|
22
|
+
| `opendkim.conf` | OpenDKIM config — signs outbound, verifies inbound, stamps `Authentication-Results` (runs as the `opendkim` user). |
|
|
23
|
+
| `opendmarc.conf` | OpenDMARC config — evaluates DMARC alignment on inbound and rejects failures when the sender publishes `p=reject` (gated by `DMARC_POLICY`). |
|
|
24
|
+
| `opendmarc.ignore.hosts` | Internal ranges OpenDMARC skips (our own outbound). |
|
|
25
|
+
| `policyd-spf.conf` | postfix-policyd-spf-python config — SPF check; rejects hard fails, feeds the result to OpenDMARC. |
|
|
26
|
+
| `KeyTable` / `SigningTable` | Generated-at-runtime stubs — `provision.py` rewrites them, one line per domain, from the pushed manifest. |
|
|
27
|
+
| `TrustedHosts` | Internal ranges OpenDKIM signs for (loopback + RFC1918). |
|
|
28
|
+
| `relay-to-app.sh` | Postfix pipe transport: POSTs each inbound message to your app's Action Mailbox relay ingress. |
|
|
29
|
+
|
|
30
|
+
`main.cf` contains a `__MAIL_DOMAIN__` placeholder (the MTA's own hostname) that `entrypoint.sh`
|
|
31
|
+
substitutes **at container start** from the `MAIL_DOMAIN` env var — do not edit it in. The DKIM keys and
|
|
32
|
+
per-domain `KeyTable`/`SigningTable`/transport are not templated; the app pushes them at runtime.
|
|
33
|
+
|
|
34
|
+
## Runtime environment (set when you run the container)
|
|
35
|
+
|
|
36
|
+
| Var | Required | What |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `MAIL_DOMAIN` | yes | The MTA's own FQDN (HELO / PTR / TLS CN), e.g. `agent.example.com`. The **agent domains** it signs/receives for come from the app's push, not this var. |
|
|
39
|
+
| `APP_RELAY_URL` | yes | Your app's relay ingress, e.g. `https://agent.example.com/rails/action_mailbox/relay/inbound_emails`. |
|
|
40
|
+
| `RAILS_INBOUND_EMAIL_PASSWORD` | yes | Shared secret (HTTP Basic). Authenticates both the inbound relay POST (user `actionmailbox`) and the app's provisioning push (user `provision`). Must match `config.action_mailbox.ingress_password`. |
|
|
41
|
+
| `DMARC_POLICY` | no | Inbound enforcement mode: `enforce` (default — reject SPF/DKIM/DMARC failures at SMTP) or `monitor` (evaluate + stamp headers, reject nothing). |
|
|
42
|
+
| `MAIL_PROVISION_PORT` | no | Internal port the provisioning listener binds (default `8025`). Not published — reachable only from the app on the Kamal network. |
|
|
43
|
+
|
|
44
|
+
Persist `/var/lib/protege-mail` on a volume so the pushed keys + cached manifest survive restarts (the
|
|
45
|
+
container re-applies the cached manifest on boot). No per-domain `DKIM_PRIVATE_KEY` secret is needed
|
|
46
|
+
anymore — the app is the source of truth and pushes the keys.
|
|
47
|
+
|
|
48
|
+
## Build
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
docker build --platform linux/amd64 -t YOUR_REGISTRY/protege-mail:latest deploy/mail
|
|
52
|
+
docker push YOUR_REGISTRY/protege-mail:latest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`kamal-accessory.example.yml` and `.github/workflows/build-mail-image.example.yml` (also generated)
|
|
56
|
+
are starting points for running the image via Kamal and building it in CI.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Container entrypoint for the Protege mail accessory.
|
|
3
|
+
#
|
|
4
|
+
# Turns the static image into a running multi-domain MTA: substitutes MAIL_DOMAIN (the box's own FQDN)
|
|
5
|
+
# into main.cf, wires the inbound pipe transport, restores the last-known domain set from the cached
|
|
6
|
+
# manifest, then launches OpenDKIM + Postfix. The signing keys + per-domain tables are NOT baked in — the
|
|
7
|
+
# Rails app pushes them to provision.py (boot re-apply + the "Sync now" listener), so one image serves
|
|
8
|
+
# every agent domain and adding one needs no rebuild.
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
# --- Required runtime configuration ------------------------------------------------------------------
|
|
12
|
+
: "${MAIL_DOMAIN:?MAIL_DOMAIN not set}"
|
|
13
|
+
: "${APP_RELAY_URL:?APP_RELAY_URL not set}"
|
|
14
|
+
: "${RAILS_INBOUND_EMAIL_PASSWORD:?RAILS_INBOUND_EMAIL_PASSWORD not set}"
|
|
15
|
+
|
|
16
|
+
# --- Substitute the box hostname into main.cf --------------------------------------------------------
|
|
17
|
+
# Only main.cf carries __MAIL_DOMAIN__ now (myhostname/mydomain — the MTA's own identity for HELO/PTR).
|
|
18
|
+
# The OpenDKIM KeyTable/SigningTable and the Postfix transport are generated per-domain by provision.py.
|
|
19
|
+
sed -i "s/__MAIL_DOMAIN__/${MAIL_DOMAIN}/g" /etc/postfix/main.cf
|
|
20
|
+
chmod 644 /etc/postfix/main.cf
|
|
21
|
+
|
|
22
|
+
# --- Provisioning bootstrap: keys + per-domain tables come from the app, not the image ----------------
|
|
23
|
+
# The DKIM keys and the Postfix transport/relay_domains are generated by provision.py from the manifest
|
|
24
|
+
# the Rails app pushes (see Protege::Gateway.push_mail_provisioning). Persist them on a volume
|
|
25
|
+
# (/var/lib/protege-mail) so a restart keeps working, and ensure an empty transport map exists so
|
|
26
|
+
# `postfix check` passes before the first push. The apply below restores the last-known domain set from
|
|
27
|
+
# the cached manifest — a no-op on first boot, before any push.
|
|
28
|
+
mkdir -p /var/lib/protege-mail/keys
|
|
29
|
+
touch /etc/postfix/transport
|
|
30
|
+
postmap /etc/postfix/transport
|
|
31
|
+
python3 /usr/local/bin/provision.py apply || true
|
|
32
|
+
|
|
33
|
+
# --- Inbound DMARC enforcement toggle ----------------------------------------------------------------
|
|
34
|
+
# DMARC_POLICY controls whether OpenDMARC rejects failing mail at SMTP. "enforce" (the default) rejects
|
|
35
|
+
# spoofed/failing mail from domains publishing p=reject; anything else ("monitor") only evaluates and
|
|
36
|
+
# stamps Authentication-Results headers, rejecting nothing — useful for a first-run observation window.
|
|
37
|
+
: "${DMARC_POLICY:=enforce}"
|
|
38
|
+
if [ "$DMARC_POLICY" = "enforce" ]; then dmarc_reject=true; else dmarc_reject=false; fi
|
|
39
|
+
sed -i "s/__DMARC_REJECT__/${dmarc_reject}/" /etc/opendmarc.conf
|
|
40
|
+
echo "protege-mail: DMARC_POLICY=${DMARC_POLICY} (RejectFailures ${dmarc_reject})" >&2
|
|
41
|
+
|
|
42
|
+
# --- Pass the relay credentials to the pipe transport ------------------------------------------------
|
|
43
|
+
# Postfix's pipe daemon scrubs the environment, so the forwarding script reads these from a file it
|
|
44
|
+
# sources. Readable only by the pipe user (nobody).
|
|
45
|
+
mkdir -p /etc/protege-mail
|
|
46
|
+
cat > /etc/protege-mail/relay.env <<EOF
|
|
47
|
+
APP_RELAY_URL='${APP_RELAY_URL}'
|
|
48
|
+
RAILS_INBOUND_EMAIL_PASSWORD='${RAILS_INBOUND_EMAIL_PASSWORD}'
|
|
49
|
+
EOF
|
|
50
|
+
chown nobody:nogroup /etc/protege-mail/relay.env
|
|
51
|
+
chmod 600 /etc/protege-mail/relay.env
|
|
52
|
+
|
|
53
|
+
# --- Register the inbound pipe transport in master.cf ------------------------------------------------
|
|
54
|
+
# transport_maps (main.cf) routes the agent domain to "actionmailbox:", which resolves to this service.
|
|
55
|
+
postconf -M "actionmailbox/unix=actionmailbox unix - n n - - pipe flags=Rq user=nobody argv=/usr/local/bin/relay-to-app.sh"
|
|
56
|
+
|
|
57
|
+
# --- Register the policyd-spf policy service in master.cf --------------------------------------------
|
|
58
|
+
# main.cf's check_policy_service unix:private/policyd-spf resolves to this spawn service. spawn runs the
|
|
59
|
+
# Python daemon per request under its own unprivileged user.
|
|
60
|
+
postconf -M "policyd-spf/unix=policyd-spf unix - n n - 0 spawn user=policyd-spf argv=/usr/bin/policyd-spf"
|
|
61
|
+
|
|
62
|
+
# --- TLS: self-signed cert so inbound STARTTLS handshakes complete -----------------------------------
|
|
63
|
+
# Postfix advertises STARTTLS (smtpd_tls_security_level = may); without a cert the handshake fails, and
|
|
64
|
+
# TLS-preferring senders (e.g. Gmail) won't downgrade to plaintext, so mail never delivers. A
|
|
65
|
+
# self-signed cert is fine for opportunistic MX TLS — peers encrypt without validating it.
|
|
66
|
+
tls_dir=/etc/postfix/tls
|
|
67
|
+
mkdir -p "$tls_dir"
|
|
68
|
+
if [ ! -s "${tls_dir}/cert.pem" ]; then
|
|
69
|
+
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
|
70
|
+
-subj "/CN=${MAIL_DOMAIN}" \
|
|
71
|
+
-keyout "${tls_dir}/key.pem" -out "${tls_dir}/cert.pem"
|
|
72
|
+
fi
|
|
73
|
+
chown root:postfix "${tls_dir}/key.pem"
|
|
74
|
+
chmod 640 "${tls_dir}/key.pem"
|
|
75
|
+
# Run all Postfix services un-chrooted. Chrooted services can't reach files outside
|
|
76
|
+
# /var/spool/postfix — the TLS cert (/etc/postfix/tls) and, critically, /etc/resolv.conf, so the smtp
|
|
77
|
+
# delivery agent's MX lookups fail ("Host not found"). The container is the isolation boundary, so
|
|
78
|
+
# chroot only gets in the way.
|
|
79
|
+
postconf -F '*/*/chroot=n'
|
|
80
|
+
|
|
81
|
+
# --- Minimal syslog so OpenDKIM's logs reach the container stdout ------------------------------------
|
|
82
|
+
# Postfix logs straight to stdout (maillog_file in main.cf); OpenDKIM only speaks syslog, so run a tiny
|
|
83
|
+
# rsyslog that forwards everything to stdout (no imklog — it fails inside containers).
|
|
84
|
+
cat > /etc/rsyslog.conf <<'EOF'
|
|
85
|
+
module(load="imuxsock")
|
|
86
|
+
*.* /dev/stdout
|
|
87
|
+
EOF
|
|
88
|
+
rsyslogd -n &
|
|
89
|
+
|
|
90
|
+
# --- Launch OpenDKIM + OpenDMARC (background) then Postfix (foreground = container PID) ---------------
|
|
91
|
+
mkdir -p /run/opendkim
|
|
92
|
+
chown opendkim:opendkim /run/opendkim
|
|
93
|
+
opendkim -x /etc/opendkim.conf
|
|
94
|
+
|
|
95
|
+
mkdir -p /run/opendmarc
|
|
96
|
+
chown opendmarc:opendmarc /run/opendmarc
|
|
97
|
+
opendmarc -c /etc/opendmarc.conf
|
|
98
|
+
|
|
99
|
+
newaliases 2>/dev/null || true
|
|
100
|
+
|
|
101
|
+
# Re-assert Postfix queue ownership before starting. The queue persists on a Docker volume, but the
|
|
102
|
+
# dynamically-allocated `postfix` UID can drift when the image is rebuilt (apt may create other system
|
|
103
|
+
# users during the transaction, shifting the uid) — leaving the existing /var/spool/postfix/* dirs owned
|
|
104
|
+
# by the previous uid. Postfix then dies with "scan_dir_push: open directory defer: Permission denied"
|
|
105
|
+
# and crash-loops. set-permissions re-chowns the queue to the current uid (verified to fix it). It also
|
|
106
|
+
# tries to chown the command man pages, which this slim image omits, so it exits non-zero *after* the
|
|
107
|
+
# queue is already fixed — tolerate that (|| true) so `set -e` doesn't abort the boot.
|
|
108
|
+
postfix set-permissions 2>/dev/null || true
|
|
109
|
+
|
|
110
|
+
# --- Domain provisioning listener ---------------------------------------------------------------------
|
|
111
|
+
# Receives pushes from the app (any EmailDomain change + the console "Sync now" button): it rewrites the
|
|
112
|
+
# OpenDKIM keys/tables and Postfix transport/relay_domains, then reloads. Internal only — bound to the
|
|
113
|
+
# Kamal network, never published (port 25 stays the sole exposed port); auth reuses
|
|
114
|
+
# RAILS_INBOUND_EMAIL_PASSWORD. See provision.py.
|
|
115
|
+
python3 /usr/local/bin/provision.py serve &
|
|
116
|
+
|
|
117
|
+
postfix check
|
|
118
|
+
exec postfix start-fg
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Kamal accessory for the self-hosted Postfix + OpenDKIM MTA.
|
|
2
|
+
#
|
|
3
|
+
# Merge this `mail:` entry into the `accessories:` block of your config/deploy.yml, then replace the
|
|
4
|
+
# placeholders (YOUR_REGISTRY, YOUR_VPS_IP, agent.example.com). The image is built from deploy/mail/
|
|
5
|
+
# and published separately — Kamal accessories pull a prebuilt image, they don't build one.
|
|
6
|
+
#
|
|
7
|
+
# Note: `kamal deploy` does NOT boot accessories. After deploying, run: kamal accessory boot mail
|
|
8
|
+
#
|
|
9
|
+
# The only secret is RAILS_INBOUND_EMAIL_PASSWORD (must be present in .kamal/secrets) — it authenticates
|
|
10
|
+
# both the inbound relay POST and the provisioning push from the app. DKIM keys are NOT a secret here:
|
|
11
|
+
# the app pushes them to the accessory's internal provisioning listener whenever a domain changes, and
|
|
12
|
+
# they persist on the protege_mail_provision volume across restarts.
|
|
13
|
+
#
|
|
14
|
+
# For the app to reach this accessory, set MAIL_RELAY_HOST: protege-mail in the APP service's env (the
|
|
15
|
+
# same name it already delivers outbound SMTP to). The provisioning listener is internal-only — do not
|
|
16
|
+
# publish its port; port 25 is the sole exposed port.
|
|
17
|
+
|
|
18
|
+
accessories:
|
|
19
|
+
mail:
|
|
20
|
+
image: YOUR_REGISTRY/protege-mail:latest
|
|
21
|
+
host: YOUR_VPS_IP
|
|
22
|
+
port: "25:25" # MX inbound + direct outbound delivery (provisioning listener stays internal)
|
|
23
|
+
env:
|
|
24
|
+
clear:
|
|
25
|
+
MAIL_DOMAIN: agent.example.com # the MTA's own FQDN (HELO/PTR); agent domains come from the app
|
|
26
|
+
APP_RELAY_URL: https://agent.example.com/rails/action_mailbox/relay/inbound_emails
|
|
27
|
+
DMARC_POLICY: enforce # inbound SPF/DKIM/DMARC; "monitor" to observe before rejecting
|
|
28
|
+
secret:
|
|
29
|
+
- RAILS_INBOUND_EMAIL_PASSWORD # relay ingress + provisioning-push auth (shared with the app)
|
|
30
|
+
volumes:
|
|
31
|
+
- "protege_mail_spool:/var/spool/postfix" # persist the Postfix queue across restarts
|
|
32
|
+
- "protege_mail_provision:/var/lib/protege-mail" # persist pushed DKIM keys + cached manifest
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Postfix main configuration for the Protege mail accessory.
|
|
2
|
+
#
|
|
3
|
+
# __MAIL_DOMAIN__ is substituted by entrypoint.sh from the MAIL_DOMAIN env var at container start, so
|
|
4
|
+
# the same image works for any agent domain.
|
|
5
|
+
|
|
6
|
+
# Identity / banner. myhostname must match the PTR (reverse DNS) of the VPS IP for good deliverability.
|
|
7
|
+
myhostname = __MAIL_DOMAIN__
|
|
8
|
+
mydomain = __MAIL_DOMAIN__
|
|
9
|
+
myorigin = $mydomain
|
|
10
|
+
|
|
11
|
+
# Listen on all interfaces (port 25 is published to the host by the Kamal accessory) and use IPv4.
|
|
12
|
+
inet_interfaces = all
|
|
13
|
+
inet_protocols = ipv4
|
|
14
|
+
|
|
15
|
+
# Inbound: accept mail addressed to any agent domain we serve. We do NOT deliver locally — the served
|
|
16
|
+
# domains are listed in relay_domains (so reject_unauth_destination permits them) and routed to the
|
|
17
|
+
# action-mailbox pipe (see master.cf) via the transport map, which forwards each to the Rails relay
|
|
18
|
+
# ingress. Both are generated per-domain at runtime by provision.py from the manifest the app pushes —
|
|
19
|
+
# relay_domains is set via `postconf -e`, and transport is a hash map — so one image serves every domain.
|
|
20
|
+
mydestination =
|
|
21
|
+
relay_domains =
|
|
22
|
+
transport_maps = hash:/etc/postfix/transport
|
|
23
|
+
|
|
24
|
+
# Relay control: the app container (on the Kamal network) hands us outbound mail. Trust RFC1918 ranges
|
|
25
|
+
# so the Docker/Kamal bridge network can relay; reject everything else (no open relay).
|
|
26
|
+
mynetworks = 127.0.0.0/8 [::1]/128 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
|
27
|
+
smtpd_relay_restrictions = permit_mynetworks reject_unauth_destination
|
|
28
|
+
|
|
29
|
+
# Inbound SPF: hand each remote sender to the policyd-spf policy service, which rejects explicit hard
|
|
30
|
+
# fails (the domain's "-all") and stamps an Authentication-Results header for OpenDMARC to consume.
|
|
31
|
+
# permit_mynetworks runs first, so our own outbound (the app container) skips the SPF check. The spawn
|
|
32
|
+
# service is registered in master.cf at runtime by entrypoint.sh; the time limit must exceed its DNS work.
|
|
33
|
+
smtpd_recipient_restrictions =
|
|
34
|
+
permit_mynetworks,
|
|
35
|
+
reject_unauth_destination,
|
|
36
|
+
check_policy_service unix:private/policyd-spf
|
|
37
|
+
policyd-spf_time_limit = 3600
|
|
38
|
+
|
|
39
|
+
# Outbound: deliver directly to recipient MX (no smarthost / relay provider). Requires outbound port 25.
|
|
40
|
+
relayhost =
|
|
41
|
+
|
|
42
|
+
# Milters. OpenDKIM (8891) signs outbound + verifies inbound DKIM. OpenDMARC (8893) runs AFTER it on
|
|
43
|
+
# inbound: it reads the SPF (policyd-spf) and DKIM (OpenDKIM) Authentication-Results, evaluates DMARC,
|
|
44
|
+
# and rejects spoofed/failing mail when the sender publishes p=reject (see opendmarc.conf RejectFailures).
|
|
45
|
+
# Order matters — OpenDKIM must precede OpenDMARC so its result is present. milter_default_action = accept
|
|
46
|
+
# fails open: a milter outage lets mail through rather than bouncing everything. non_smtpd_milters stays
|
|
47
|
+
# OpenDKIM-only — locally reinjected/outbound mail is signed, not DMARC-checked.
|
|
48
|
+
milter_default_action = accept
|
|
49
|
+
milter_protocol = 6
|
|
50
|
+
smtpd_milters = inet:127.0.0.1:8891, inet:127.0.0.1:8893
|
|
51
|
+
non_smtpd_milters = inet:127.0.0.1:8891
|
|
52
|
+
|
|
53
|
+
# TLS for opportunistic encryption. The entrypoint generates a self-signed cert at /etc/postfix/tls so
|
|
54
|
+
# inbound STARTTLS handshakes complete — Postfix advertises STARTTLS, and TLS-preferring senders (e.g.
|
|
55
|
+
# Gmail) refuse to downgrade, so a missing cert means mail never delivers. Opportunistic MX TLS does
|
|
56
|
+
# not validate the peer cert, so self-signed is sufficient.
|
|
57
|
+
smtp_tls_security_level = may
|
|
58
|
+
smtpd_tls_security_level = may
|
|
59
|
+
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
|
|
60
|
+
smtpd_tls_cert_file = /etc/postfix/tls/cert.pem
|
|
61
|
+
smtpd_tls_key_file = /etc/postfix/tls/key.pem
|
|
62
|
+
|
|
63
|
+
# Modern Postfix housekeeping.
|
|
64
|
+
compatibility_level = 3.6
|
|
65
|
+
maillog_file = /dev/stdout
|
|
66
|
+
|
|
67
|
+
# Size limit (25 MB) — agent mail is text/attachments, not bulk.
|
|
68
|
+
message_size_limit = 26214400
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# OpenDKIM configuration for the Protege mail accessory.
|
|
2
|
+
#
|
|
3
|
+
# Signs outbound mail for the agent domain with the key materialized from EmailDomain (DKIM_PRIVATE_KEY,
|
|
4
|
+
# selector "default") and verifies inbound signatures. Postfix talks to it over the inet milter socket.
|
|
5
|
+
|
|
6
|
+
# Sign and verify.
|
|
7
|
+
Mode sv
|
|
8
|
+
UserID opendkim
|
|
9
|
+
|
|
10
|
+
# Milter socket — must match smtpd_milters/non_smtpd_milters in main.cf.
|
|
11
|
+
Socket inet:8891@127.0.0.1
|
|
12
|
+
|
|
13
|
+
# Map sender domains to selectors/keys (KeyTable) and decide what to sign (SigningTable). TrustedHosts
|
|
14
|
+
# lists internal hosts whose mail is signed rather than verified.
|
|
15
|
+
KeyTable /etc/opendkim/KeyTable
|
|
16
|
+
SigningTable refile:/etc/opendkim/SigningTable
|
|
17
|
+
InternalHosts /etc/opendkim/TrustedHosts
|
|
18
|
+
ExternalIgnoreList /etc/opendkim/TrustedHosts
|
|
19
|
+
|
|
20
|
+
# Canonicalization: relaxed/simple is the widely-compatible default (survives minor in-transit edits to
|
|
21
|
+
# headers without breaking the body hash).
|
|
22
|
+
Canonicalization relaxed/simple
|
|
23
|
+
|
|
24
|
+
# Stamp the inbound DKIM verdict into an Authentication-Results header under this authserv-id. OpenDMARC
|
|
25
|
+
# (which runs next in the milter chain) trusts the same id and reads the DKIM result from here for the
|
|
26
|
+
# DKIM-alignment half of DMARC. Keep it identical to opendmarc.conf's AuthservID/TrustedAuthservIDs and
|
|
27
|
+
# policyd-spf.conf's Authserv_Id.
|
|
28
|
+
AuthservID protege
|
|
29
|
+
|
|
30
|
+
SignatureAlgorithm rsa-sha256
|
|
31
|
+
OversignHeaders From
|
|
32
|
+
|
|
33
|
+
# Logging to syslog (tailed to stdout via rsyslog).
|
|
34
|
+
Syslog yes
|
|
35
|
+
SyslogSuccess yes
|
|
36
|
+
UMask 002
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# OpenDMARC configuration for the Protege mail accessory.
|
|
2
|
+
#
|
|
3
|
+
# OpenDMARC is the inbound enforcement linchpin: it reads the SPF result (from policyd-spf) and the DKIM
|
|
4
|
+
# result (from OpenDKIM) out of the Authentication-Results headers stamped under our shared authserv-id,
|
|
5
|
+
# evaluates DMARC alignment against the sender domain's published policy, and rejects messages that fail
|
|
6
|
+
# when that policy says p=reject. Runs as a milter AFTER OpenDKIM (see smtpd_milters order in main.cf).
|
|
7
|
+
|
|
8
|
+
# Shared authserv-id. Both OpenDKIM and policyd-spf stamp Authentication-Results under this name, and
|
|
9
|
+
# OpenDMARC only trusts results bearing it (TrustedAuthservIDs) — so a forged A-R header from a remote
|
|
10
|
+
# sender is ignored.
|
|
11
|
+
AuthservID protege
|
|
12
|
+
TrustedAuthservIDs protege
|
|
13
|
+
|
|
14
|
+
# Milter socket — must match the second entry in main.cf's smtpd_milters.
|
|
15
|
+
Socket inet:8893@127.0.0.1
|
|
16
|
+
PidFile /run/opendmarc/opendmarc.pid
|
|
17
|
+
UserID opendmarc
|
|
18
|
+
|
|
19
|
+
# Don't DMARC-check mail we ourselves authenticated/relayed, and skip our internal ranges (the app
|
|
20
|
+
# container's outbound mail) — those are listed in ignore.hosts.
|
|
21
|
+
IgnoreAuthenticatedClients true
|
|
22
|
+
IgnoreHosts /etc/opendmarc/ignore.hosts
|
|
23
|
+
|
|
24
|
+
# Reject at SMTP when a message fails DMARC and the sender publishes p=reject. The placeholder is
|
|
25
|
+
# substituted from the DMARC_POLICY env var by entrypoint.sh: "enforce" -> true, anything else -> false
|
|
26
|
+
# (monitor mode: evaluate + stamp headers, reject nothing).
|
|
27
|
+
RejectFailures __DMARC_REJECT__
|
|
28
|
+
|
|
29
|
+
# Honor the sender's p=quarantine as a soft signal rather than a reject (we have no spam folder; the
|
|
30
|
+
# Authentication-Results header still records the failure for the app/audit).
|
|
31
|
+
SoftwareHeader true
|
|
32
|
+
Syslog true
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Hosts OpenDMARC must NOT evaluate — our own infrastructure. The app container relays outbound mail
|
|
2
|
+
# from the Kamal/Docker bridge (RFC1918), and we don't DMARC-check our own outbound. Mirrors the
|
|
3
|
+
# OpenDKIM TrustedHosts and main.cf mynetworks ranges.
|
|
4
|
+
127.0.0.1
|
|
5
|
+
::1
|
|
6
|
+
localhost
|
|
7
|
+
10.0.0.0/8
|
|
8
|
+
172.16.0.0/12
|
|
9
|
+
192.168.0.0/16
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# postfix-policyd-spf-python configuration for the Protege mail accessory.
|
|
2
|
+
#
|
|
3
|
+
# Runs as a Postfix policy service (see the spawn entry registered by entrypoint.sh and the
|
|
4
|
+
# check_policy_service line in main.cf). It checks the sending IP against the envelope domain's SPF
|
|
5
|
+
# record and feeds the result to OpenDMARC. permit_mynetworks runs first in main.cf, so our own
|
|
6
|
+
# outbound (the app container) never reaches this service.
|
|
7
|
+
|
|
8
|
+
# Reject only an explicit hard fail (the domain published "-all" — it disavows this IP). Soft fails
|
|
9
|
+
# (~all) and neutral/none are NOT rejected here; DMARC arbitrates those so legitimate forwarded mail
|
|
10
|
+
# that still DKIM-aligns can pass.
|
|
11
|
+
HELO_reject = False
|
|
12
|
+
Mail_From_reject = Fail
|
|
13
|
+
|
|
14
|
+
# Don't reject on transient/permanent SPF lookup problems — annotate and move on (DMARC decides).
|
|
15
|
+
PermError_reject = False
|
|
16
|
+
TempError_Defer = False
|
|
17
|
+
|
|
18
|
+
# Emit the SPF verdict as an Authentication-Results header under the shared authserv-id so OpenDMARC
|
|
19
|
+
# trusts and consumes it for the SPF-alignment half of DMARC.
|
|
20
|
+
Header_Type = AR
|
|
21
|
+
Authserv_Id = protege
|