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,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
|