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,137 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Scan engine views and helpers for Tailwind class usage */
4
+ @source "../../views/**/*.html.erb";
5
+ @source "../../views/**/*.html.slim";
6
+ @source "../../helpers/**/*.rb";
7
+
8
+ /* ── Type scale ──────────────────────────────────────────────
9
+ The dashboard leans on the small end of the ramp (text-xs / text-sm) throughout, which reads
10
+ cramped at Tailwind's defaults. Retune the scale tokens up one notch here — in one place — so every
11
+ text-xs/text-sm/… keeps its semantic meaning and simply renders a step larger, rather than scaling
12
+ the root font-size (which would also stretch rem-based spacing). Line-heights track the defaults.
13
+ */
14
+ @theme {
15
+ --text-xs: 0.8125rem; /* 12 → 13px */
16
+ --text-sm: 0.9375rem; /* 14 → 15px */
17
+ --text-base: 1.0625rem; /* 16 → 17px */
18
+ --text-lg: 1.25rem; /* 18 → 20px */
19
+ }
20
+
21
+ /* ── Theme tokens ────────────────────────────────────────────
22
+ Semantic color variables that flip with dark mode.
23
+ Components use these via arbitrary value syntax:
24
+ bg-[var(--surface)] text-[var(--text)]
25
+ or we map them below to utility overrides.
26
+ */
27
+ :root {
28
+ --surface: theme(colors.white);
29
+ --surface-subtle: theme(colors.gray.50);
30
+ --border: theme(colors.gray.200);
31
+ --text: theme(colors.gray.900);
32
+ --text-muted: theme(colors.gray.500);
33
+ --text-faint: theme(colors.gray.400);
34
+ }
35
+
36
+ .dark {
37
+ --surface: theme(colors.gray.950);
38
+ --surface-subtle: theme(colors.gray.900);
39
+ --border: theme(colors.gray.800);
40
+ --text: theme(colors.gray.100);
41
+ --text-muted: theme(colors.gray.400);
42
+ --text-faint: theme(colors.gray.600);
43
+ }
44
+
45
+ /* Apply theme tokens globally */
46
+ body {
47
+ background-color: var(--surface);
48
+ color: var(--text);
49
+ }
50
+
51
+ /* Button hover — light fill on hover for outline buttons */
52
+ .btn-outline:hover {
53
+ background-color: var(--surface-subtle) !important;
54
+ }
55
+ .btn-danger:hover {
56
+ background-color: rgba(239, 68, 68, 0.1) !important;
57
+ }
58
+
59
+ /* Empty-state placeholder for a live container (e.g. the introspection activity feed): shows its
60
+ data-empty text only while it has no children, so it disappears the moment content streams in. */
61
+ .introspection-empty:empty::before {
62
+ content: attr(data-empty);
63
+ color: var(--text-faint);
64
+ font-size: 0.75rem;
65
+ }
66
+
67
+ /* Click-to-copy affordance (paired with copy.js). Shows the native copy cursor on hover where the
68
+ browser supports it, falling back to the hand cursor otherwise (the earlier declaration wins when
69
+ `copy` is unknown). */
70
+ [data-copy] {
71
+ cursor: pointer;
72
+ cursor: copy;
73
+ }
74
+
75
+ /* Drag-to-resize handle (paired with resize.js). An invisible grab strip laid over a container's edge;
76
+ its ::before draws the visible divider line, which thickens and brightens on hover so the resize
77
+ affordance is discoverable. Defaults to a full-height strip on the container's left edge (the common
78
+ vertical-divider case); override top/right/bottom/left/width/cursor for another edge. */
79
+ .resize-handle {
80
+ position: absolute;
81
+ top: 0;
82
+ bottom: 0;
83
+ left: 0;
84
+ width: 9px;
85
+ z-index: 20;
86
+ cursor: col-resize;
87
+ }
88
+ .resize-handle::before {
89
+ content: "";
90
+ position: absolute;
91
+ top: 0;
92
+ bottom: 0;
93
+ left: 0;
94
+ width: 1px;
95
+ background: var(--border);
96
+ transition: width 0.12s ease, background-color 0.12s ease;
97
+ }
98
+ .resize-handle:hover::before,
99
+ .resize-handle:active::before {
100
+ width: 3px;
101
+ background: var(--text-faint);
102
+ }
103
+
104
+ /* Right-edge variant — the divider sits on the container's right edge (e.g. the inbox sidebar). */
105
+ .resize-handle--right {
106
+ left: auto;
107
+ right: 0;
108
+ }
109
+ .resize-handle--right::before {
110
+ left: auto;
111
+ right: 0;
112
+ }
113
+
114
+ /* Top-edge variant — a full-width horizontal divider on the container's top edge (e.g. the reply
115
+ composer), dragged vertically. */
116
+ .resize-handle--top {
117
+ top: 0;
118
+ bottom: auto;
119
+ left: 0;
120
+ right: 0;
121
+ width: auto;
122
+ height: 9px;
123
+ cursor: row-resize;
124
+ }
125
+ .resize-handle--top::before {
126
+ top: 0;
127
+ bottom: auto;
128
+ left: 0;
129
+ right: 0;
130
+ width: auto;
131
+ height: 1px;
132
+ }
133
+ .resize-handle--top:hover::before,
134
+ .resize-handle--top:active::before {
135
+ width: auto;
136
+ height: 3px;
137
+ }
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Shared concern for controllers nested under a persona.
5
+ # Sets +@persona+ from +params[:persona_id]+.
6
+ module PersonaScoped
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :set_persona
11
+ end
12
+
13
+ private
14
+
15
+ # Load the +Persona+ named by +params[:persona_id]+ into +@persona+.
16
+ #
17
+ # @return [Protege::Persona] the loaded persona
18
+ def set_persona
19
+ @persona = Persona.find(params[:persona_id])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Shared concern for controllers nested under a thread.
5
+ # Sets +@thread+ from +params[:thread_id]+.
6
+ module ThreadScoped
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_action :set_thread
11
+ end
12
+
13
+ private
14
+
15
+ # Load the +EmailThread+ named by +params[:thread_id]+ into +@thread+.
16
+ #
17
+ # @return [Protege::EmailThread] the loaded thread
18
+ def set_thread
19
+ @thread = EmailThread.find(params[:thread_id])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages a persona's runtime inbound access rules — the dashboard-editable, narrowing layer of the
5
+ # access guardrail (see +AccessRule+ and +AccessControl+). Nested under personas via the
6
+ # +PersonaScoped+ concern, which loads +@persona+ from +params[:persona_id]+ before every action; each
7
+ # action renders back to the persona's page.
8
+ class AccessRulesController < ApplicationController
9
+ include PersonaScoped
10
+
11
+ before_action :set_access_rule, only: [:destroy]
12
+
13
+ # Add an access rule to the persona. Serves +POST /personas/:persona_id/access_rules+.
14
+ #
15
+ # Turbo requests update the rules table in place (append the row + reset the form on success, or
16
+ # re-render the form with errors); non-Turbo requests redirect back with the result in the flash.
17
+ #
18
+ # @return [void]
19
+ def create
20
+ @access_rule = @persona.access_rules.build(access_rule_params)
21
+ saved = @access_rule.save
22
+
23
+ respond_to do |format|
24
+ format.turbo_stream { render :create, status: saved ? :ok : :unprocessable_entity }
25
+ format.html { redirect_to persona_path(@persona), **create_flash(saved) }
26
+ end
27
+ end
28
+
29
+ # Remove an access rule from the persona. Serves
30
+ # +DELETE /personas/:persona_id/access_rules/:id+.
31
+ #
32
+ # Turbo requests remove the row in place; non-Turbo requests redirect back to the persona.
33
+ #
34
+ # @return [void]
35
+ def destroy
36
+ @access_rule.destroy
37
+
38
+ respond_to do |format|
39
+ format.turbo_stream { render turbo_stream: turbo_stream.remove(@access_rule) }
40
+ format.html { redirect_to persona_path(@persona), notice: 'Access rule removed.' }
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # The flash for the non-Turbo create redirect — a success notice, or the validation errors.
47
+ #
48
+ # @param saved [Boolean] whether the rule saved
49
+ # @return [Hash] the +redirect_to+ flash options
50
+ def create_flash(saved)
51
+ if saved
52
+ { notice: 'Access rule added.' }
53
+ else
54
+ { alert: @access_rule.errors.full_messages.to_sentence }
55
+ end
56
+ end
57
+
58
+ # Load the access rule
59
+ #
60
+ # @return [Protege::AccessRule] the access rule to destroy
61
+ def set_access_rule
62
+ @access_rule = @persona.access_rules.find(params[:id])
63
+ end
64
+
65
+ # Permit the rule's kind and address pattern.
66
+ #
67
+ # @return [ActionController::Parameters] the permitted +:kind+ and +:pattern+
68
+ def access_rule_params
69
+ params.require(:access_rule).permit(:kind, :pattern)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Base controller for all Protege engine controllers.
5
+ #
6
+ # Declares the engine layout explicitly so Rails never falls back to the
7
+ # host application's layout when rendering engine views.
8
+ #
9
+ # Engine route helpers (e.g. +thread_path+) are exposed to views via
10
+ # +helper+ so they are available in partials rendered both from requests
11
+ # and from Turbo broadcast rendering.
12
+ class ApplicationController < ActionController::Base
13
+ layout 'protege/application'
14
+
15
+ helper Protege::ApplicationHelper
16
+ helper Protege::Engine.routes.url_helpers
17
+ end
18
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Archives and unarchives a persona — the reversible, data-preserving alternative to permanent deletion.
5
+ #
6
+ # Archiving is modeled as an +archive+ resource on the persona: +create+ archives it (stamps
7
+ # +archived_at+), +destroy+ unarchives it (clears it). An archived persona stops receiving mail and
8
+ # running scheduled responsibilities, but all its history is kept and it can be restored at any time.
9
+ #
10
+ # Both actions re-render the persona's action bar in place over Turbo (the sidebar row updates
11
+ # separately via +BroadcastablePersona+), falling back to a full redirect for non-Turbo requests.
12
+ class ArchivesController < ApplicationController
13
+ include PersonaScoped
14
+
15
+ # Archive the persona. Serves +POST /personas/:persona_id/archive+.
16
+ #
17
+ # @return [void]
18
+ def create
19
+ @persona.archive!
20
+
21
+ respond_with_persona(notice: 'Persona archived — it no longer receives mail or runs scheduled tasks.')
22
+ end
23
+
24
+ # Unarchive the persona. Serves +DELETE /personas/:persona_id/archive+.
25
+ #
26
+ # @return [void]
27
+ def destroy
28
+ @persona.unarchive!
29
+
30
+ respond_with_persona(notice: 'Persona restored.')
31
+ end
32
+
33
+ private
34
+
35
+ # Replace the persona's action bar over Turbo, or redirect back to the persona for a full request.
36
+ #
37
+ # @param notice [String] the flash message for the redirect fallback
38
+ # @return [void]
39
+ def respond_with_persona(notice:)
40
+ respond_to do |format|
41
+ format.turbo_stream do
42
+ render turbo_stream: turbo_stream.replace(
43
+ helpers.dom_id(@persona, :actions),
44
+ partial: 'protege/personas/actions',
45
+ locals: { persona: @persona }
46
+ )
47
+ end
48
+ format.html { redirect_to persona_path(@persona), notice: }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages email domains the engine receives mail for.
5
+ #
6
+ # Creating a domain generates a DKIM keypair automatically. The show
7
+ # page surfaces all DNS records the operator must configure before the
8
+ # domain goes live.
9
+ class EmailDomainsController < ApplicationController
10
+ before_action :set_email_domain, only: %i[show destroy]
11
+
12
+ # List every configured email domain, ordered by name. Serves +GET /email_domains+.
13
+ #
14
+ # @return [void]
15
+ def index
16
+ @email_domains = EmailDomain.order(:domain)
17
+ end
18
+
19
+ # Render the form for adding a new email domain. Serves +GET /email_domains/new+.
20
+ #
21
+ # @return [void]
22
+ def new
23
+ @email_domain = EmailDomain.new
24
+ end
25
+
26
+ # Create an email domain, generating its DKIM keypair on save. Serves +POST /email_domains+.
27
+ #
28
+ # On success, redirect to the show page so the operator can read the DNS
29
+ # records; on failure, re-render the form with validation errors.
30
+ #
31
+ # @return [void]
32
+ def create
33
+ @email_domain = EmailDomain.new(email_domain_params)
34
+
35
+ if @email_domain.save
36
+ redirect_to email_domain_path(@email_domain), notice: 'Domain added. Configure the DNS records below.'
37
+ else
38
+ render :new, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ # Show a single domain and the DNS records required to activate it. Serves +GET /email_domains/:id+.
43
+ #
44
+ # @return [void]
45
+ def show; end
46
+
47
+ # Delete an email domain. Serves +DELETE /email_domains/:id+.
48
+ #
49
+ # @return [void]
50
+ def destroy
51
+ @email_domain.destroy!
52
+
53
+ redirect_to email_domains_path, notice: 'Domain removed.'
54
+ end
55
+
56
+ private
57
+
58
+ # Load the +EmailDomain+ named by +params[:id]+ into +@email_domain+.
59
+ #
60
+ # @return [Protege::EmailDomain] the loaded domain
61
+ def set_email_domain
62
+ @email_domain = EmailDomain.find(params[:id])
63
+ end
64
+
65
+ # Permit the domain attributes accepted from the new-domain form.
66
+ #
67
+ # @return [ActionController::Parameters] the permitted +:domain+ and +:dkim_selector+
68
+ def email_domain_params
69
+ params.require(:email_domain).permit(:domain, :dkim_selector)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Serves the dashboard landing page mounted at the engine root.
5
+ #
6
+ # The page is static — it renders the +show+ view as the entry point into the
7
+ # Protege dashboard, from which operators navigate to personas, threads, and
8
+ # email domains.
9
+ class HomeController < ApplicationController
10
+ # Render the dashboard landing page. Serves +GET /+.
11
+ #
12
+ # @return [void]
13
+ def show; end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Messages — the console email search and per-message delete, both keyed off the globally-unique
5
+ # message, so neither is thread-scoped.
6
+ #
7
+ # +index+ (+GET /messages+) is the cross-persona search view: query params filter the messages via
8
+ # +MessageSearch+ and the matches render in the sidebar, each linking to its thread. +destroy+
9
+ # (+DELETE /messages/:id+) removes a single message by id.
10
+ class MessagesController < ApplicationController
11
+ before_action :set_message, only: :destroy
12
+
13
+ # Search emails across all threads. Serves +GET /messages+ (+?from=&recipients=&subject=&body=
14
+ # &attachments=&page=+); blank params yield no results.
15
+ #
16
+ # @return [void]
17
+ def index
18
+ @search = MessageSearch.from_params(params)
19
+ @messages = @search.results
20
+ end
21
+
22
+ # Delete a single message. Serves +DELETE /messages/:id+.
23
+ #
24
+ # Responds with a Turbo Stream that removes the message element in place, or falls back to
25
+ # redirecting to its thread for non-Turbo requests.
26
+ #
27
+ # @return [void]
28
+ def destroy
29
+ @message.destroy!
30
+
31
+ respond_to do |format|
32
+ format.turbo_stream { render turbo_stream: turbo_stream.remove(@message) }
33
+ format.html { redirect_to thread_path(@message.email_thread) }
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # Load the +Message+ named by +params[:id]+ into +@message+.
40
+ #
41
+ # @return [Protege::Message] the loaded message
42
+ def set_message
43
+ @message = Message.find(params[:id])
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Pushes the whole domain manifest to the self-hosted MTA on demand — the console "Sync now" button.
5
+ #
6
+ # Domain changes already re-provision automatically (+EmailDomain+ enqueues +MailProvisioningJob+ on
7
+ # commit); this manual re-push is for recovery — e.g. after the accessory was rebooted, or to confirm
8
+ # the MTA holds the current keys. Nested under a domain for context, but the push is always the full
9
+ # manifest (the MTA needs complete state), so the specific domain in the URL is incidental.
10
+ class MtaDomainsSyncsController < ApplicationController
11
+ # Push the manifest and return to the domain. Serves
12
+ # +POST /email_domains/:email_domain_id/mta_domains_sync+.
13
+ #
14
+ # @return [void]
15
+ def create
16
+ result = Gateway.push_mail_provisioning
17
+
18
+ redirect_to email_domain_path(params[:email_domain_id]),
19
+ flash: { (result[:ok] ? :notice : :alert) => result[:message] }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages persona records — listing, creating, viewing, editing, and permanent deletion.
5
+ #
6
+ # A persona is the identity behind an inbox; the controller surfaces the full CRUD the dashboard
7
+ # needs. +destroy+ is the hard, irreversible removal (cascading all the persona's data); the
8
+ # reversible, history-preserving alternative is archiving — see +ArchivesController+.
9
+ class PersonasController < ApplicationController
10
+ before_action :set_persona, only: %i[show edit update destroy]
11
+
12
+ # List every persona. Serves +GET /personas+.
13
+ #
14
+ # @return [void]
15
+ def index
16
+ @personas = Persona.all
17
+ end
18
+
19
+ # Show a single persona. Serves +GET /personas/:id+.
20
+ #
21
+ # @return [void]
22
+ def show; end
23
+
24
+ # Render the form for creating a persona. Serves +GET /personas/new+.
25
+ #
26
+ # @return [void]
27
+ def new
28
+ @persona = Persona.new
29
+ end
30
+
31
+ # Render the form for editing a persona. Serves +GET /personas/:id/edit+.
32
+ #
33
+ # @return [void]
34
+ def edit; end
35
+
36
+ # Create a persona from the form's split email fields. Serves +POST /personas+.
37
+ #
38
+ # On failure, re-render the form with validation errors.
39
+ #
40
+ # @return [void]
41
+ def create
42
+ @persona = Persona.new(create_params)
43
+
44
+ if @persona.save
45
+ redirect_to persona_path(@persona), notice: 'Persona created.'
46
+ else
47
+ render :new, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ # Update an existing persona. Serves +PATCH /personas/:id+.
52
+ #
53
+ # On failure, re-render the edit form with validation errors.
54
+ #
55
+ # @return [void]
56
+ def update
57
+ if @persona.update(update_params)
58
+ redirect_to persona_path(@persona), notice: 'Persona updated.'
59
+ else
60
+ render :edit, status: :unprocessable_entity
61
+ end
62
+ end
63
+
64
+ # Permanently delete a persona and everything it owns. Serves +DELETE /personas/:id+.
65
+ #
66
+ # This is the hard, irreversible removal (the reversible path is Archive — see +ArchivesController+):
67
+ # the model cascades the persona's threads, messages, responsibilities, access rules, and tool-use
68
+ # history. +BroadcastablePersona+ removes its sidebar row.
69
+ #
70
+ # @return [void]
71
+ def destroy
72
+ @persona.destroy!
73
+
74
+ redirect_to personas_path, notice: 'Persona deleted.'
75
+ end
76
+
77
+ private
78
+
79
+ # Load the +Persona+ named by +params[:id]+ into +@persona+.
80
+ #
81
+ # @return [Protege::Persona] the loaded persona
82
+ def set_persona
83
+ @persona = Persona.find(params[:id])
84
+ end
85
+
86
+ # Permit creation attributes and fold the split email fields into a single address.
87
+ #
88
+ # @return [ActionController::Parameters] the permitted attributes plus +:email_address+
89
+ def create_params
90
+ params.require(:persona)
91
+ .permit(:type, :name, :instructions)
92
+ .merge(email_address: compose_email_address)
93
+ end
94
+
95
+ # Permit the editable attributes and recompose the address from the split email fields. Changing the
96
+ # address is allowed — only mail to the *old* address stops routing here; all history keys off
97
+ # +persona_id+, not the address. +disabled_tool_ids+ is the tool checklist submitted straight from
98
+ # the form (the model strips the hidden blank); a PATCH without that field leaves the scope untouched.
99
+ #
100
+ # @return [ActionController::Parameters] the permitted attributes plus +:email_address+
101
+ def update_params
102
+ params.require(:persona)
103
+ .permit(:name, :instructions, disabled_tool_ids: [])
104
+ .merge(email_address: compose_email_address)
105
+ end
106
+
107
+ # Combine the split prefix + domain fields from the form into a single
108
+ # email address.
109
+ #
110
+ # @return [String] the +prefix@domain+ address with surrounding whitespace stripped
111
+ def compose_email_address
112
+ params[:persona].values_at(:email_prefix, :email_domain)
113
+ .map(&:to_s)
114
+ .map(&:strip)
115
+ .join('@')
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Handles replies within an existing thread from the dashboard
5
+ # console. Nested under threads via the +ThreadScoped+ concern, which loads
6
+ # +@thread+ from +params[:thread_id]+.
7
+ class RepliesController < ApplicationController
8
+ include ThreadScoped
9
+
10
+ # Build a reply mail and hand it to the +Gateway+ for inbound processing.
11
+ # Serves +POST /threads/:thread_id/reply+.
12
+ #
13
+ # The console acts as the sender; the reply is addressed to the thread's
14
+ # persona so the LOGI loop processes it as if it arrived over SMTP.
15
+ #
16
+ # The new message inserts itself into the thread over Turbo (its +after_create_commit+ broadcast,
17
+ # see +BroadcastableMessage+), so a Turbo request needs no body — it just returns +201 Created+, which
18
+ # Turbo treats as "handled, no navigation". Skipping the redirect keeps the live introspection panel
19
+ # intact, so a new run appends below the prior ones instead of resetting. Non-Turbo clients redirect.
20
+ #
21
+ # @return [void]
22
+ def create
23
+ Gateway.accept_console_message(
24
+ mail: build_reply_mail,
25
+ persona: @thread.persona,
26
+ attachments: reply_params[:files] || []
27
+ )
28
+
29
+ respond_to do |format|
30
+ format.turbo_stream { head :created }
31
+ format.html { redirect_to thread_path(@thread) }
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # Permit the reply body and any uploaded files submitted from the console form.
38
+ #
39
+ # @return [ActionController::Parameters] the permitted +:body+ and +:files+
40
+ def reply_params
41
+ params.require(:reply).permit(:body, files: [])
42
+ end
43
+
44
+ # Construct a threaded reply +Mail+ object from the console.
45
+ #
46
+ # Threads the message by chaining +In-Reply-To+ to the last message and +References+ to every
47
+ # prior message ID, preserving the conversation. Assembly (synthetic id, charset, date, threading
48
+ # header normalization) is delegated to +Gateway.build_outbound+.
49
+ #
50
+ # @return [Mail::Message] the composed reply ready for the +Gateway+
51
+ def build_reply_mail
52
+ messages = @thread.messages.order(:sent_at)
53
+
54
+ Gateway.build_outbound(
55
+ from: Protege.configuration.console_address,
56
+ to: @thread.persona.email_address,
57
+ subject: @thread.subject,
58
+ body: reply_params[:body],
59
+ in_reply_to: messages.last&.message_id,
60
+ references: messages.pluck(:message_id)
61
+ )
62
+ end
63
+ end
64
+ end