mailbox-kit 0.1.0

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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +104 -0
  4. data/app/controllers/mailbox_kit/management/mailboxes_controller.rb +171 -0
  5. data/app/controllers/mailbox_kit/management/styles_controller.rb +11 -0
  6. data/app/views/layouts/mailbox_kit/management.html.erb +29 -0
  7. data/app/views/mailbox_kit/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/mailbox_kit/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/mailbox_kit/management/mailboxes/show.html.erb +86 -0
  10. data/docs/integration.md +283 -0
  11. data/docs/upgrading.md +60 -0
  12. data/lib/generators/mailbox_kit/install/install_generator.rb +24 -0
  13. data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_mailboxes.rb +51 -0
  14. data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_receiving_domains.rb +16 -0
  15. data/lib/generators/mailbox_kit/upgrade/templates/allow_provider_neutral_receiving_domains.rb +9 -0
  16. data/lib/generators/mailbox_kit/upgrade/templates/index_mailbox_kit_inbound_messages.rb +12 -0
  17. data/lib/generators/mailbox_kit/upgrade/upgrade_generator.rb +23 -0
  18. data/lib/mailbox-kit.rb +11 -0
  19. data/lib/mailbox_kit/active_record/base.rb +58 -0
  20. data/lib/mailbox_kit/address_syntax.rb +19 -0
  21. data/lib/mailbox_kit/error.rb +14 -0
  22. data/lib/mailbox_kit/inbound_email.rb +56 -0
  23. data/lib/mailbox_kit/mailboxes/configuration.rb +19 -0
  24. data/lib/mailbox_kit/mailboxes/inbound_retention.rb +16 -0
  25. data/lib/mailbox_kit/mailboxes/models.rb +152 -0
  26. data/lib/mailbox_kit/mailboxes/service.rb +312 -0
  27. data/lib/mailbox_kit/mailboxes.rb +9 -0
  28. data/lib/mailbox_kit/management/adapter.rb +29 -0
  29. data/lib/mailbox_kit/management/configuration.rb +15 -0
  30. data/lib/mailbox_kit/management/engine.rb +13 -0
  31. data/lib/mailbox_kit/management/management.css +68 -0
  32. data/lib/mailbox_kit/management/routes.rb +14 -0
  33. data/lib/mailbox_kit/management.rb +6 -0
  34. data/lib/mailbox_kit/railtie.rb +21 -0
  35. data/lib/mailbox_kit/tenancy.rb +76 -0
  36. data/lib/mailbox_kit/tenant_job_context.rb +88 -0
  37. data/lib/mailbox_kit/version.rb +3 -0
  38. metadata +81 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 186fcaea489075063aeb25ee886080ff243289eb3ec02fdfac110f9c7e5c9081
4
+ data.tar.gz: 81071f06cf54c2de754bf16a056e6be93ae7dcbbca6604c3636a8e3ed19e3169
5
+ SHA512:
6
+ metadata.gz: 6df3f4be7cd2b1b07eebbd164a609c4fa488572b2bed5c65312e1363e4c21c04312ad11d3b6cf73cf636ff0893dc7d4b066a31f7f842beb1d1a829819db783ef
7
+ data.tar.gz: ba69a55126f0a291dc21296230532c4c575a183d45f49c37dbeeb4295762847a43c47b7fa091c8e835b2b0cd3bdbf87fd697baebd9244ff52345d46795997df5
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cole
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Mailbox Kit
2
+
3
+ Persistent inboxes and a management UI built on Rails Action Mailbox.
4
+ Use it when your application needs named inboxes, aliases, read/archive state,
5
+ and access control. SQLite works out of the box; database tenancy is opt-in.
6
+
7
+ Rails already stores raw email, parses it, routes it to handlers, and runs
8
+ processing jobs. Mailbox Kit adds the inbox your users interact with.
9
+
10
+ ## Choose your setup
11
+
12
+ - **Cloudflare receiving or sending:** follow the [Cloudflare Rails guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
13
+ It includes this core; use the Cloudflare installer.
14
+ - **An existing Action Mailbox integration:** install this core and attach received
15
+ email to inboxes. Follow the example below.
16
+ - **Only processing incoming email:** Action Mailbox alone may be enough.
17
+
18
+ Add Mailbox Kit 0.1 to your Gemfile:
19
+
20
+ ```ruby
21
+ gem "mailbox-kit", "~> 0.1.0"
22
+ ```
23
+
24
+ For a new Rails 7.2–8.1 application:
25
+
26
+ ```sh
27
+ bundle install
28
+ bin/rails action_mailbox:install
29
+ bin/rails generate mailbox_kit:install
30
+ bin/rails db:migrate
31
+ ```
32
+
33
+ Already using Cloudflare mailboxes? Follow the [upgrade guide](docs/upgrading.md)
34
+ instead of creating the tables again.
35
+
36
+ ## Create an inbox in code
37
+
38
+ After configuring and verifying your provider's receiving route:
39
+
40
+ ```ruby
41
+ boxes = MailboxKit::Mailboxes
42
+ domain = boxes.register_domain(domain: "in.example.com", tenant_key: "workspace")
43
+ boxes.activate_domain!(domain.id, evidence: "Receiving route verified")
44
+
45
+ boxes.for_tenant("workspace") do |session|
46
+ inbox = session.create(name: "Support", address: "support@in.example.com")
47
+ session.activate_address!(inbox.addresses.first.id, evidence: "Receiving route verified")
48
+ end
49
+ ```
50
+
51
+ `workspace` scopes inboxes within the default database. It does not enable separate
52
+ tenant databases. Creating an address does not configure DNS or authorize sending.
53
+
54
+ ## Connect received email
55
+
56
+ If your authenticated integration already has an Action Mailbox record:
57
+
58
+ ```ruby
59
+ MailboxKit::Mailboxes.for_tenant(trusted_tenant_key) do |session|
60
+ session.attach(recipient: verified_envelope_recipient,
61
+ inbound_email_id: inbound_email.id)
62
+ end
63
+ ```
64
+
65
+ Authorize the record in the application first. Use the provider's verified
66
+ envelope recipient, not an untrusted email header. Keep your ordinary
67
+ `ApplicationMailbox` handlers: attachment does not process the email again.
68
+ The Cloudflare inbox ingress already performs this integration for you.
69
+
70
+ ## Read and manage
71
+
72
+ ```ruby
73
+ MailboxKit::Mailboxes.for_tenant("workspace") do |session|
74
+ unread = session.messages(inbox_id).inbox.unread
75
+ session.mark_read(inbox_id, message_id)
76
+ session.archive(inbox_id, message_id)
77
+ end
78
+ ```
79
+
80
+ The optional management engine provides a server-rendered UI with no frontend
81
+ build. Your application supplies authentication and authorizes each action;
82
+ access is denied until configured. See [management setup](docs/integration.md#management-interface).
83
+
84
+ ## The split
85
+
86
+ | Layer | Owns |
87
+ | --- | --- |
88
+ | Rails | Raw MIME, Active Storage, parsing, routing, processing jobs/status, cleanup policy, Action Mailer |
89
+ | Mailbox Kit | Inbox identities, addresses/aliases, memberships, read/archive state, selective retention, optional tenant context, management UI |
90
+ | Cloudflare | Request verification, authoritative envelope metadata, Worker retries/storage, sending, delivery feedback, provisioning |
91
+ | Your app | Users/organizations/sites, ownership lifecycle, authorization, sender acceptance, business effects |
92
+
93
+ Inbound and outbound can use different providers through Rails. The core does
94
+ not send email or supply a universal outbound adapter registry. `owner_ref` is an
95
+ application-managed reference; it is not an automatic `has_mailbox` association
96
+ or permission grant.
97
+
98
+ Inbox-associated messages survive Rails' automatic incineration. For retaining
99
+ all inbound messages, Rails already offers `config.action_mailbox.incinerate = false`.
100
+ Raw storage and processing remain Rails responsibilities.
101
+
102
+ See the [integration guide](docs/integration.md) for aliases, catch-all addresses,
103
+ verified source ingestion, duplicates, retention, authorization and optional
104
+ database tenancy. See [upgrading](docs/upgrading.md) for existing installations.
@@ -0,0 +1,171 @@
1
+ module MailboxKit
2
+ module Management
3
+ class MailboxesController < ActionController::Base
4
+ layout "layouts/mailbox_kit/management"
5
+ protect_from_forgery with: :exception
6
+ self.forgery_protection_origin_check = true
7
+ around_action :with_host_context
8
+ helper_method :allowed?, :host_back_path
9
+
10
+ class Denied < StandardError; end
11
+ class InvalidInput < StandardError; end
12
+
13
+ rescue_from Denied do
14
+ head :forbidden
15
+ end
16
+ rescue_from "ActiveRecord::RecordNotFound" do
17
+ head :not_found
18
+ end
19
+ rescue_from InvalidInput, "ActiveRecord::RecordInvalid", MailboxKit::Error do
20
+ redirect_to(@mailbox ? mailbox_path(@mailbox) : root_path,
21
+ alert: "The changes could not be saved. Check the address, registered domain and required fields.",
22
+ status: :see_other)
23
+ end
24
+ rescue_from MailboxKit::ConfigurationError do
25
+ head :service_unavailable
26
+ end
27
+
28
+ def index
29
+ authorize!(:index)
30
+ @mailboxes = page(scoped_mailboxes)
31
+ @domains = permitted_domains if allowed?(:create)
32
+ end
33
+
34
+ def show
35
+ load_mailbox!(:show)
36
+ @addresses = @session.addresses(@mailbox.id).order(:id).to_a
37
+ @domains = allowed?(:add_address, @mailbox) ? permitted_domains : []
38
+ @messages = allowed?(:show_message, @mailbox) ? page(@session.messages(@mailbox.id)) : []
39
+ end
40
+
41
+ def create
42
+ authorize!(:create)
43
+ input = params.require(:mailbox)
44
+ raise InvalidInput unless input.is_a?(ActionController::Parameters)
45
+ values = input.permit(:name, :address)
46
+ name = values[:name].to_s.strip
47
+ raise InvalidInput unless (1..255).cover?(name.length)
48
+ address = permitted_address!(values[:address])
49
+ Mailboxes::Mailbox.transaction do
50
+ @mailbox = @adapter.create_mailbox(@session, name: name, address: address)
51
+ # A hook cannot turn an arbitrary return value into a readable mailbox.
52
+ @mailbox = scoped_mailboxes.find(@mailbox.id)
53
+ end
54
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox created. Review address setup below.", status: :see_other
55
+ end
56
+
57
+ def add_address
58
+ load_mailbox!(:add_address)
59
+ @adapter.add_address(@session, @mailbox, address: permitted_address!(params[:address]))
60
+ redirect_to mailbox_path(@mailbox), notice: "Address added. Review its setup status.", status: :see_other
61
+ end
62
+
63
+ def suspend
64
+ load_mailbox!(:suspend)
65
+ @session.suspend(@mailbox.id)
66
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox suspended. Messages are retained.", status: :see_other
67
+ end
68
+
69
+ def resume
70
+ load_mailbox!(:resume)
71
+ @session.resume(@mailbox.id)
72
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox resumed. Pending addresses still need setup.", status: :see_other
73
+ end
74
+
75
+ def message
76
+ load_mailbox!(:show_message)
77
+ @entry = @session.messages(@mailbox.id).find(params[:message_id])
78
+ raw = @session.inbound_email(@mailbox.id, @entry.id).mail
79
+ @subject = raw.subject.to_s
80
+ @from = Array(raw.from).join(", ")
81
+ part = raw.text_part || (raw.mime_type == "text/plain" ? raw : nil)
82
+ @body = part ? part.decoded.to_s.encode("UTF-8", invalid: :replace, undef: :replace).first(100_000) :
83
+ "This message has no plain-text body. HTML rendering is disabled in this management interface."
84
+ @attachments = raw.attachments.map { |attachment| attachment.filename.to_s }
85
+ render :message
86
+ end
87
+
88
+ def mark_read
89
+ load_mailbox!(:mark_read)
90
+ @session.mark_read(@mailbox.id, params[:message_id], read: boolean_param(:read))
91
+ redirect_to mailbox_path(@mailbox), status: :see_other
92
+ end
93
+
94
+ def archive
95
+ load_mailbox!(:archive)
96
+ @session.archive(@mailbox.id, params[:message_id], archived: boolean_param(:archived))
97
+ redirect_to mailbox_path(@mailbox), status: :see_other
98
+ end
99
+
100
+ private
101
+
102
+ def with_host_context
103
+ response.headers["Cache-Control"] = "no-store"
104
+ response.headers["Referrer-Policy"] = "same-origin"
105
+ response.headers["X-Content-Type-Options"] = "nosniff"
106
+ response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
107
+ factory = Management.configuration.adapter
108
+ unless factory.respond_to?(:call) && defined?(Mailboxes) && Mailboxes.enabled?
109
+ return head :service_unavailable
110
+ end
111
+ @adapter = factory.call(self)
112
+ authenticated = @adapter.authenticate!
113
+ return if performed?
114
+ return head :unauthorized unless authenticated == true
115
+ Mailboxes.for_tenant(@adapter.tenant_key) do |session|
116
+ @session = session
117
+ yield
118
+ end
119
+ end
120
+
121
+ def allowed?(action, mailbox = nil)
122
+ @adapter.allowed?(action, mailbox) == true
123
+ end
124
+
125
+ def authorize!(action, mailbox = nil)
126
+ raise Denied unless allowed?(action, mailbox)
127
+ end
128
+
129
+ def scoped_mailboxes
130
+ # Always retain the gem's tenant condition even if a host scope omits it.
131
+ @session.mailboxes.where(id: @adapter.mailboxes(@session).reselect(:id))
132
+ end
133
+
134
+ def load_mailbox!(action)
135
+ @mailbox = scoped_mailboxes.find(params[:id])
136
+ authorize!(action, @mailbox)
137
+ end
138
+
139
+ def permitted_domains
140
+ granted = Array(@adapter.domains(@session)).map(&:to_s)
141
+ Mailboxes::ReceivingDomain.active.where(tenant_key: @session.tenant_key, domain: granted).order(:domain).pluck(:domain)
142
+ end
143
+
144
+ def permitted_address!(value)
145
+ address = Mailboxes.canonical_address(value)
146
+ raise InvalidInput unless permitted_domains.include?(address.split("@", 2).last)
147
+ address
148
+ end
149
+
150
+ def page(relation)
151
+ cursor = params[:after].to_s
152
+ raise InvalidInput unless cursor.empty? || cursor.match?(/\A[0-9]{1,18}\z/)
153
+ rows = relation.where("id > ?", cursor.to_i).order(:id).limit(51).to_a
154
+ @next_cursor = rows.length > 50 ? rows[49].id : nil
155
+ rows.first(50)
156
+ end
157
+
158
+ def boolean_param(key)
159
+ value = params[key]
160
+ raise InvalidInput unless %w[true false].include?(value)
161
+ value == "true"
162
+ end
163
+
164
+ def host_back_path
165
+ callback = Management.configuration.back_path
166
+ value = callback.call(self) if callback.respond_to?(:call)
167
+ value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") && !value.match?(/[\\\r\n]/)
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,11 @@
1
+ module MailboxKit
2
+ module Management
3
+ class StylesController < ActionController::Base
4
+ def show
5
+ response.headers["X-Content-Type-Options"] = "nosniff"
6
+ send_data File.binread(File.expand_path("../../../../lib/mailbox_kit/management/management.css", __dir__)),
7
+ type: "text/css; charset=utf-8", disposition: "inline"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Mailbox management</title>
7
+ <%= csrf_meta_tags %>
8
+ <%= stylesheet_link_tag style_path %>
9
+ </head>
10
+ <body>
11
+ <a class="skip-link" href="#main">Skip to content</a>
12
+ <header class="site-header">
13
+ <div class="shell header-content">
14
+ <%= link_to "Mailbox management", root_path, class: "brand" %>
15
+ <span class="header-caption">Mailboxes</span>
16
+ <% if host_back_path %><%= link_to "Back to app", host_back_path %><% end %>
17
+ </div>
18
+ </header>
19
+ <main id="main" class="shell" tabindex="-1">
20
+ <% if flash[:notice].present? %>
21
+ <div class="notice" role="status"><%= flash[:notice] %></div>
22
+ <% end %>
23
+ <% if flash[:alert].present? %>
24
+ <div class="notice notice-error" role="alert"><%= flash[:alert] %></div>
25
+ <% end %>
26
+ <%= yield %>
27
+ </main>
28
+ </body>
29
+ </html>
@@ -0,0 +1,65 @@
1
+ <div class="page-heading">
2
+ <div>
3
+ <p class="eyebrow">Email workspace</p>
4
+ <h1>Mailboxes</h1>
5
+ <p class="muted">Manage accepted addresses and the messages delivered to them.</p>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="workspace-grid">
10
+ <section class="panel" aria-labelledby="mailboxes-heading">
11
+ <div class="panel-heading"><h2 id="mailboxes-heading">Your mailboxes</h2></div>
12
+ <% if @mailboxes.empty? %>
13
+ <div class="empty-state">
14
+ <h3>No mailboxes to show</h3>
15
+ <p>Create a mailbox to give incoming email a home.</p>
16
+ </div>
17
+ <% else %>
18
+ <ul class="record-list">
19
+ <% @mailboxes.each do |mailbox| %>
20
+ <li class="record-row">
21
+ <div class="record-content">
22
+ <% if allowed?(:show, mailbox) %>
23
+ <%= link_to mailbox.name, mailbox_path(mailbox.id), class: "record-title" %>
24
+ <% else %>
25
+ <span class="record-title"><%= mailbox.name %></span>
26
+ <% end %>
27
+ <p class="muted small">Mailbox #<%= mailbox.id %></p>
28
+ </div>
29
+ <span class="badge"><%= mailbox.state.capitalize %></span>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ <% end %>
34
+ <% if @next_cursor.present? %>
35
+ <nav class="pagination" aria-label="Mailbox pages">
36
+ <%= link_to "Next mailboxes →", root_path(after: @next_cursor), class: "button button-secondary" %>
37
+ </nav>
38
+ <% end %>
39
+ </section>
40
+
41
+ <% if allowed?(:create) %>
42
+ <section class="panel" aria-labelledby="create-heading">
43
+ <div class="panel-heading"><h2 id="create-heading">Create a mailbox</h2></div>
44
+ <div class="panel-body">
45
+ <% if @domains.empty? %>
46
+ <p class="muted">An administrator needs to register an active receiving domain before you can create a mailbox.</p>
47
+ <% else %>
48
+ <%= form_with url: mailboxes_path, scope: :mailbox, local: true, class: "stack" do |form| %>
49
+ <div class="field">
50
+ <%= form.label :name, "Mailbox name" %>
51
+ <%= form.text_field :name, required: true, maxlength: 255, placeholder: "Customer support" %>
52
+ </div>
53
+ <div class="field">
54
+ <%= form.label :address, "Email address" %>
55
+ <%= form.email_field :address, required: true, maxlength: 254, placeholder: "support@#{@domains.first}", aria: { describedby: "create-domains" } %>
56
+ <p id="create-domains" class="field-help">Available domains: <%= @domains.join(", ") %></p>
57
+ </div>
58
+ <p class="field-help">New addresses start pending. Your administrator must confirm their routing before they can receive mail.</p>
59
+ <%= form.submit "Create mailbox", class: "button" %>
60
+ <% end %>
61
+ <% end %>
62
+ </div>
63
+ </section>
64
+ <% end %>
65
+ </div>
@@ -0,0 +1,34 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb">
2
+ <%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span>
3
+ <%= link_to @mailbox.name, mailbox_path(@mailbox.id) %> <span aria-hidden="true">/</span>
4
+ <span>Message</span>
5
+ </nav>
6
+ <div class="page-heading">
7
+ <div><p class="eyebrow">Received message</p><h1><%= @subject.presence || "(No subject)" %></h1></div>
8
+ <div class="actions">
9
+ <% if allowed?(:mark_read, @mailbox) %>
10
+ <%= button_to(@entry.read_at ? "Mark unread" : "Mark read", mark_read_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, read: @entry.read_at ? "false" : "true" }, class: "button button-secondary") %>
11
+ <% end %>
12
+ <% if allowed?(:archive, @mailbox) %>
13
+ <%= button_to(@entry.archived_at ? "Unarchive" : "Archive", archive_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, archived: @entry.archived_at ? "false" : "true" }, class: "button button-secondary") %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+ <article class="panel" aria-label="Email message">
18
+ <div class="panel-body">
19
+ <dl class="message-metadata">
20
+ <dt>From</dt><dd><%= @from.presence || "(Unknown sender)" %></dd>
21
+ <dt>To</dt><dd><%= @entry.recipient %></dd>
22
+ <dt>Received</dt><dd><time datetime="<%= @entry.created_at.iso8601 %>"><%= @entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></dd>
23
+ <dt>Status</dt><dd><%= @entry.read_at ? "Read" : "Unread" %><%= " · Archived" if @entry.archived_at %></dd>
24
+ </dl>
25
+ </div>
26
+ <div class="message-body"><pre><%= @body.presence || "No text content is available for this message." %></pre></div>
27
+ <% if @attachments.any? %>
28
+ <section class="panel-body attachments" aria-labelledby="attachments-heading">
29
+ <h2 id="attachments-heading">Attachments</h2>
30
+ <p class="field-help">File names are shown for reference. Downloads are not available in this dashboard.</p>
31
+ <ul><% @attachments.each do |filename| %><li><%= filename.presence || "Unnamed attachment" %></li><% end %></ul>
32
+ </section>
33
+ <% end %>
34
+ </article>
@@ -0,0 +1,86 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span> <span><%= @mailbox.name %></span></nav>
2
+ <div class="page-heading">
3
+ <div>
4
+ <p class="eyebrow">Mailbox</p>
5
+ <h1><%= @mailbox.name %></h1>
6
+ <p class="muted"><%= @mailbox.state == "suspended" ? "Receiving is paused for every address in this mailbox." : "Addresses receive mail when both their address and domain are active." %></p>
7
+ </div>
8
+ <div class="actions">
9
+ <span class="badge"><%= @mailbox.state.capitalize %></span>
10
+ <% if @mailbox.state == "active" && allowed?(:suspend, @mailbox) %>
11
+ <%= button_to "Suspend mailbox", suspend_mailbox_path(@mailbox.id), method: :post, class: "button button-secondary" %>
12
+ <% elsif @mailbox.state == "suspended" && allowed?(:resume, @mailbox) %>
13
+ <%= button_to "Resume mailbox", resume_mailbox_path(@mailbox.id), method: :post, class: "button" %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="workspace-grid">
19
+ <section class="panel" aria-labelledby="messages-heading">
20
+ <div class="panel-heading"><h2 id="messages-heading">Received messages</h2><p class="muted small">Includes archived messages</p></div>
21
+ <% if @messages.empty? %>
22
+ <div class="empty-state"><h3>No messages to show</h3><p>Messages appear here after delivery to an active address.</p></div>
23
+ <% else %>
24
+ <ul class="record-list">
25
+ <% @messages.each do |entry| %>
26
+ <li class="record-row">
27
+ <div class="record-content">
28
+ <% if allowed?(:show_message, @mailbox) %>
29
+ <%= link_to "Message to #{entry.recipient}", message_mailbox_path(@mailbox.id, message_id: entry.id), class: "record-title" %>
30
+ <% else %>
31
+ <span class="record-title">Message to <%= entry.recipient %></span>
32
+ <% end %>
33
+ <p class="muted small"><time datetime="<%= entry.created_at.iso8601 %>"><%= entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></p>
34
+ </div>
35
+ <div class="status-list">
36
+ <span class="badge"><%= entry.read_at ? "Read" : "Unread" %></span>
37
+ <% if entry.archived_at %><span class="badge">Archived</span><% end %>
38
+ </div>
39
+ </li>
40
+ <% end %>
41
+ </ul>
42
+ <% end %>
43
+ <% if @next_cursor.present? %>
44
+ <nav class="pagination" aria-label="Message pages"><%= link_to "Next messages →", mailbox_path(@mailbox.id, after: @next_cursor), class: "button button-secondary" %></nav>
45
+ <% end %>
46
+ </section>
47
+
48
+ <div class="stack">
49
+ <section class="panel" aria-labelledby="addresses-heading">
50
+ <div class="panel-heading"><h2 id="addresses-heading">Addresses</h2></div>
51
+ <ul class="record-list">
52
+ <% @addresses.each do |address| %>
53
+ <% receiving = @mailbox.state == "active" && address.state == "active" && address.receiving_domain&.state == "active" %>
54
+ <li class="record-row address-row">
55
+ <span class="record-title"><%= address.address %></span>
56
+ <% if address.has_attribute?(:catch_all) && address[:catch_all] %>
57
+ <span class="badge">Domain catch-all configured</span>
58
+ <% end %>
59
+ <span class="badge"><%= receiving ? "Accepting mail" : address.state == "pending" ? "Pending setup" : "Receiving paused" %></span>
60
+ </li>
61
+ <% end %>
62
+ </ul>
63
+ <div class="panel-body"><p class="field-help">Pending addresses need routing confirmation from your administrator. Resuming a mailbox preserves each address’s existing setup state.</p></div>
64
+ </section>
65
+
66
+ <% if allowed?(:add_address, @mailbox) %>
67
+ <section class="panel" aria-labelledby="alias-heading">
68
+ <div class="panel-heading"><h2 id="alias-heading">Add an alias</h2></div>
69
+ <div class="panel-body">
70
+ <% if @domains.empty? %>
71
+ <p class="muted">No active receiving domains are available. Contact your administrator.</p>
72
+ <% else %>
73
+ <%= form_with url: aliases_mailbox_path(@mailbox.id), local: true, class: "stack" do |form| %>
74
+ <div class="field">
75
+ <%= form.label :address, "Alias email address" %>
76
+ <%= form.email_field :address, required: true, maxlength: 254, aria: { describedby: "alias-domains" } %>
77
+ <p id="alias-domains" class="field-help">Available domains: <%= @domains.join(", ") %>. Messages to this alias will appear in this mailbox.</p>
78
+ </div>
79
+ <%= form.submit "Add alias", class: "button" %>
80
+ <% end %>
81
+ <% end %>
82
+ </div>
83
+ </section>
84
+ <% end %>
85
+ </div>
86
+ </div>