protege 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,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,3 @@
1
+ # generated at runtime by provision.py — do not edit
2
+ # One line per domain, written from the manifest the Rails app pushes:
3
+ # *@<domain> <selector>._domainkey.<domain>
@@ -0,0 +1,8 @@
1
+ # Hosts whose outbound mail OpenDKIM signs (rather than treating as external to verify). Covers
2
+ # loopback and the private Docker/Kamal bridge ranges the app container relays through.
3
+ 127.0.0.1
4
+ ::1
5
+ localhost
6
+ 10.0.0.0/8
7
+ 172.16.0.0/12
8
+ 192.168.0.0/16
@@ -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