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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +104 -0
- data/app/controllers/mailbox_kit/management/mailboxes_controller.rb +171 -0
- data/app/controllers/mailbox_kit/management/styles_controller.rb +11 -0
- data/app/views/layouts/mailbox_kit/management.html.erb +29 -0
- data/app/views/mailbox_kit/management/mailboxes/index.html.erb +65 -0
- data/app/views/mailbox_kit/management/mailboxes/message.html.erb +34 -0
- data/app/views/mailbox_kit/management/mailboxes/show.html.erb +86 -0
- data/docs/integration.md +283 -0
- data/docs/upgrading.md +60 -0
- data/lib/generators/mailbox_kit/install/install_generator.rb +24 -0
- data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_mailboxes.rb +51 -0
- data/lib/generators/mailbox_kit/install/templates/create_mailbox_kit_receiving_domains.rb +16 -0
- data/lib/generators/mailbox_kit/upgrade/templates/allow_provider_neutral_receiving_domains.rb +9 -0
- data/lib/generators/mailbox_kit/upgrade/templates/index_mailbox_kit_inbound_messages.rb +12 -0
- data/lib/generators/mailbox_kit/upgrade/upgrade_generator.rb +23 -0
- data/lib/mailbox-kit.rb +11 -0
- data/lib/mailbox_kit/active_record/base.rb +58 -0
- data/lib/mailbox_kit/address_syntax.rb +19 -0
- data/lib/mailbox_kit/error.rb +14 -0
- data/lib/mailbox_kit/inbound_email.rb +56 -0
- data/lib/mailbox_kit/mailboxes/configuration.rb +19 -0
- data/lib/mailbox_kit/mailboxes/inbound_retention.rb +16 -0
- data/lib/mailbox_kit/mailboxes/models.rb +152 -0
- data/lib/mailbox_kit/mailboxes/service.rb +312 -0
- data/lib/mailbox_kit/mailboxes.rb +9 -0
- data/lib/mailbox_kit/management/adapter.rb +29 -0
- data/lib/mailbox_kit/management/configuration.rb +15 -0
- data/lib/mailbox_kit/management/engine.rb +13 -0
- data/lib/mailbox_kit/management/management.css +68 -0
- data/lib/mailbox_kit/management/routes.rb +14 -0
- data/lib/mailbox_kit/management.rb +6 -0
- data/lib/mailbox_kit/railtie.rb +21 -0
- data/lib/mailbox_kit/tenancy.rb +76 -0
- data/lib/mailbox_kit/tenant_job_context.rb +88 -0
- data/lib/mailbox_kit/version.rb +3 -0
- 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>
|