sunabamail 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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +45 -0
  3. data/.rubocop.yml +7 -0
  4. data/CHANGELOG.md +5 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +119 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/sunabamail/application_controller.rb +4 -0
  9. data/app/controllers/sunabamail/messages/alls_controller.rb +6 -0
  10. data/app/controllers/sunabamail/messages/application_controller.rb +9 -0
  11. data/app/controllers/sunabamail/messages/attachments_controller.rb +13 -0
  12. data/app/controllers/sunabamail/messages/htmls_controller.rb +12 -0
  13. data/app/controllers/sunabamail/messages/raws_controller.rb +7 -0
  14. data/app/controllers/sunabamail/messages/sources_controller.rb +7 -0
  15. data/app/controllers/sunabamail/messages/texts_controller.rb +7 -0
  16. data/app/controllers/sunabamail/messages_controller.rb +22 -0
  17. data/app/models/sunabamail/message.rb +12 -0
  18. data/app/models/sunabamail/message_raw.rb +6 -0
  19. data/app/models/sunabamail/record.rb +5 -0
  20. data/app/views/layouts/sunabamail/application.html.erb +21 -0
  21. data/app/views/sunabamail/application/_scripts.html.erb +155 -0
  22. data/app/views/sunabamail/application/_styles.html.erb +281 -0
  23. data/app/views/sunabamail/messages/_message.html.erb +82 -0
  24. data/app/views/sunabamail/messages/index.html.erb +71 -0
  25. data/app/views/sunabamail/messages/show.html.erb +3 -0
  26. data/assets/sunabamail.png +0 -0
  27. data/config/locales/en.yml +5 -0
  28. data/config/routes.rb +16 -0
  29. data/lib/generators/sunabamail/install_generator.rb +87 -0
  30. data/lib/generators/sunabamail/templates/db/sunabamail_schema.rb +23 -0
  31. data/lib/sunabamail/delivery_method.rb +19 -0
  32. data/lib/sunabamail/engine.rb +27 -0
  33. data/lib/sunabamail/tasks.rb +6 -0
  34. data/lib/sunabamail/version.rb +5 -0
  35. data/lib/sunabamail.rb +26 -0
  36. metadata +121 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 856ebc548796776b7b192691b54668c2e81c9c2cfceb62b2057ab9358071270c
4
+ data.tar.gz: fa2b1dcb6033d4f6ad9a2af74937d07c03b2b8bdbfb2e91d2a9b11c9dd012d2e
5
+ SHA512:
6
+ metadata.gz: fa0b4dc118595c7eb59f2d368c719925ba0e90cdb4bc6b661d9d46083306fcb706c1e8396dbabc9cd4fa22507e12cd7fab8df76eff91bd9ba829b0f1610c37dd
7
+ data.tar.gz: ec79d435cba4ddfaf3bc7dae08b8abc5a2bf8ed2655172242fe7406f495baf387f1bd5cb35ac786b49bc4a2ed6f0ac1fc9be6459bbbe69391aa9a4ed398c05fe
@@ -0,0 +1,45 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ name: "Ruby ${{ matrix.ruby }} x Rails ${{ matrix.rails }}"
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ ruby: ['3.1', '3.2', '3.3', '3.4', '4.0']
17
+ rails:
18
+ - '~> 7.0.0'
19
+ - '~> 7.1.0'
20
+ - '~> 7.2.0'
21
+ - '~> 8.0.0'
22
+ - '~> 8.1.0'
23
+ - 'head'
24
+ exclude:
25
+ - ruby: '3.1'
26
+ rails: 'head'
27
+ - ruby: '3.2'
28
+ rails: 'head'
29
+ - ruby: '3.1'
30
+ rails: '~> 8.0.0'
31
+ - ruby: '3.1'
32
+ rails: '~> 8.1.0'
33
+ env:
34
+ RAILS: ${{ matrix.rails }}
35
+ steps:
36
+ - uses: actions/checkout@v5
37
+ - name: Set up Ruby
38
+ uses: ruby/setup-ruby@v1
39
+ with:
40
+ ruby-version: ${{ matrix.ruby }}
41
+ bundler-cache: true
42
+ - name: Run test
43
+ run: bundle exec rake test
44
+ - name: Run rubocop
45
+ run: bundle exec rake rubocop
data/.rubocop.yml ADDED
@@ -0,0 +1,7 @@
1
+ # Omakase Ruby styling for Rails
2
+ inherit_gem:
3
+ rubocop-rails-omakase: rubocop.yml
4
+
5
+ AllCops:
6
+ Exclude:
7
+ - 'lib/generators/sunabamail/templates/**/*'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-06
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Takashi SAKAGUCHI
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # Sunabamail
2
+
3
+ Sunabamail is an Action Mailer delivery method that stores emails in the database - so you can inspect them from anywhere via a built-in web UI.
4
+
5
+ ## Why Sunabamail?
6
+
7
+ In modern Rails applications, emails are often generated across multiple processes — web servers, background workers, and staging environments.
8
+
9
+ Sunabamail is designed for this kind of setup.
10
+ In other words, it makes emails observable regardless of where they are generated.
11
+
12
+ By storing emails in the database, it allows you to:
13
+
14
+ - Access emails from any process or server
15
+ - Inspect emails generated by background jobs
16
+ - Verify email behavior safely in staging
17
+
18
+ It works well alongside traditional development tools, while focusing on setups where emails need to be observable across processes and environments.
19
+
20
+ ## Features
21
+
22
+ - Drop-in replacement for `ActionMailer` delivery method
23
+ - Stores emails in a dedicated database (separate from your app data)
24
+ - Built-in Rails Engine UI (`/sunabamail`) for browsing emails
25
+ - Works across processes and servers
26
+ - Ideal for development and staging
27
+
28
+ ## Installation
29
+
30
+ ```sh
31
+ bundle add sunabamail --group development
32
+ bin/rails sunabamail:install
33
+ ```
34
+
35
+ This will:
36
+
37
+ - Configure action_mailer.delivery_method
38
+ - Mount the engine
39
+ - Generate db/sunabamail_schema.rb
40
+
41
+ ### Database configuration
42
+
43
+ Add a dedicated database for Sunabamail.
44
+
45
+ #### SQLite
46
+
47
+ ```yaml
48
+ development:
49
+ primary:
50
+ <<: *default
51
+ database: storage/development.sqlite3
52
+ sunabamail:
53
+ <<: *default
54
+ database: storage/development_sunabamail.sqlite3
55
+ migrations_paths: db/sunabamail_migrate
56
+ ```
57
+
58
+ #### MySQL / PostgreSQL / Trilogy
59
+
60
+ ```yaml
61
+ development:
62
+ primary: &primary_development
63
+ <<: *default
64
+ database: app_development
65
+ username: app
66
+ password: <%= ENV["APP_DATABASE_PASSWORD"] %>
67
+ sunabamail:
68
+ <<: *primary_development
69
+ database: app_development_sunabamail
70
+ migrations_paths: db/sunabamail_migrate
71
+ ```
72
+ Then run:
73
+
74
+ ```sh
75
+ bin/rails db:prepare
76
+ ```
77
+
78
+ ### Usage
79
+
80
+ Start your Rails server and visit:
81
+
82
+ ```
83
+ http://localhost:3000/sunabamail
84
+ ```
85
+
86
+ You will see a list of captured emails and their contents:
87
+
88
+ ![Web interface](assets/sunabamail.png)
89
+
90
+ You can:
91
+
92
+ - Browse captured emails
93
+ - Inspect subject, body, and headers
94
+ - Debug mailer behavior without sending real emails
95
+
96
+ ### Usage in staging
97
+
98
+ Sunabamail can also be used in staging environments.
99
+
100
+ This is especially useful when:
101
+
102
+ - You want to verify email behavior without sending real emails
103
+ - Your app runs background jobs on separate workers
104
+
105
+ **NOTE** Not intended for production use.
106
+
107
+ ## Development
108
+
109
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
110
+
111
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
112
+
113
+ ## Contributing
114
+
115
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hamajyotan/sunabamail.
116
+
117
+ ## License
118
+
119
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,4 @@
1
+ class Sunabamail::ApplicationController < ActionController::Base
2
+ # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
3
+ allow_browser versions: :modern
4
+ end
@@ -0,0 +1,6 @@
1
+ class Sunabamail::Messages::AllsController < ApplicationController
2
+ def destroy
3
+ Sunabamail::Message.destroy_all
4
+ redirect_to messages_path, notice: "destroyed"
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ class Sunabamail::Messages::ApplicationController < Sunabamail::ApplicationController
2
+ before_action :set_message
3
+
4
+ private
5
+
6
+ def set_message
7
+ @message = Sunabamail::Message.find(params.expect(:message_id))
8
+ end
9
+ end
@@ -0,0 +1,13 @@
1
+ class Sunabamail::Messages::AttachmentsController < Sunabamail::Messages::ApplicationController
2
+ before_action :set_attachment, only: %i[show]
3
+
4
+ def show
5
+ send_data(@attachment.read, filename: @attachment.filename)
6
+ end
7
+
8
+ private
9
+
10
+ def set_attachment
11
+ @attachment = @message.mail.attachments[params.expect(:id).to_i]
12
+ end
13
+ end
@@ -0,0 +1,12 @@
1
+ class Sunabamail::Messages::HtmlsController < Sunabamail::Messages::ApplicationController
2
+ def show
3
+ html = @message.mail.html_part.body.to_s.force_encoding(@message.mail.charset)
4
+ doc = Nokogiri::HTML.fragment(html)
5
+ doc.css("a").each do |a|
6
+ a["target"] = "_blank"
7
+ a["rel"] = "noopener noreferrer"
8
+ end
9
+
10
+ send_data doc.to_html, type: "text/html", disposition: "inline"
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ class Sunabamail::Messages::RawsController < Sunabamail::Messages::ApplicationController
2
+ def show
3
+ send_data @message.raw.encoded,
4
+ type: "text/plain",
5
+ disposition: "inline"
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class Sunabamail::Messages::SourcesController < Sunabamail::Messages::ApplicationController
2
+ def show
3
+ send_data @message.mail.html_part.body.to_s.force_encoding(@message.mail.charset),
4
+ type: "text/plain",
5
+ disposition: "inline"
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ class Sunabamail::Messages::TextsController < Sunabamail::Messages::ApplicationController
2
+ def show
3
+ send_data @message.mail.text_part.body.to_s.force_encoding(@message.mail.charset),
4
+ type: "text/plain",
5
+ disposition: "inline"
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ class Sunabamail::MessagesController < Sunabamail::ApplicationController
2
+ before_action :set_message, only: %i[show destroy]
3
+
4
+ def index
5
+ @messages = Sunabamail::Message.all.order(id: :desc)
6
+ @message = @messages.first
7
+ end
8
+
9
+ def show
10
+ end
11
+
12
+ def destroy
13
+ @message.destroy!
14
+ redirect_to messages_path, notice: "destroyed", status: :see_other
15
+ end
16
+
17
+ private
18
+
19
+ def set_message
20
+ @message = Sunabamail::Message.find(params.expect(:id))
21
+ end
22
+ end
@@ -0,0 +1,12 @@
1
+ class Sunabamail::Message < Sunabamail::Record
2
+ has_one :raw,
3
+ foreign_key: :sunabamail_message_id,
4
+ class_name: "Sunabamail::MessageRaw",
5
+ inverse_of: :message,
6
+ dependent: :destroy,
7
+ autosave: true
8
+
9
+ def mail
10
+ Mail::Message.new(raw.encoded)
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ class Sunabamail::MessageRaw < Sunabamail::Record
2
+ belongs_to :message,
3
+ foreign_key: :sunabamail_message_id,
4
+ class_name: "Sunabamail::Message",
5
+ inverse_of: :raw
6
+ end
@@ -0,0 +1,5 @@
1
+ class Sunabamail::Record < ActiveRecord::Base
2
+ self.abstract_class = true
3
+
4
+ connects_to(**Sunabamail.connects_to) if Sunabamail.connects_to
5
+ end
@@ -0,0 +1,21 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= "Sunabamail" %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="application-name" content="Sunabamail">
8
+ <meta name="mobile-web-app-capable" content="yes">
9
+ <%= csrf_meta_tags %>
10
+ <%= csp_meta_tag %>
11
+
12
+ <%= yield :head %>
13
+
14
+ <%= render "styles" %>
15
+ <%= render "scripts" %>
16
+ </head>
17
+
18
+ <body>
19
+ <%= yield %>
20
+ </body>
21
+ </html>
@@ -0,0 +1,155 @@
1
+ <script>
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ initConfirm();
4
+ initPaneSeparator();
5
+ initMessageList();
6
+ });
7
+
8
+ function initConfirm() {
9
+ document.addEventListener('click', function (e) {
10
+ const el = e.target.closest("[data-confirm]") || e.target.closest("[data-turbo-confirm]");
11
+ if (!el) return;
12
+
13
+ const message = el.dataset.confirm || el.dataset.turboConfirm;
14
+ if (!confirm(message)) {
15
+ e.preventDefault();
16
+ }
17
+ });
18
+ }
19
+
20
+ function initPaneSeparator() {
21
+ const separator = document.querySelector('[data-pane-separator]');
22
+ const container = document.querySelector('[data-messages-list-container]');
23
+ const listPane = document.querySelector('[data-messages-list-pane]');
24
+ if (!separator || !container || !listPane) return;
25
+
26
+ let startX = 0;
27
+ let startWidth = 0;
28
+
29
+ separator.addEventListener('pointerdown', (e) => {
30
+ e.preventDefault();
31
+
32
+ startX = e.clientX;
33
+ startWidth = listPane.offsetWidth;
34
+
35
+ separator.classList.add('dragging');
36
+ document.body.style.userSelect = 'none';
37
+
38
+ const onMove = (e) => {
39
+ const containerWidth = container.offsetWidth;
40
+ const minWidth = 200;
41
+ const maxWidth = Math.max(minWidth, containerWidth - 250);
42
+
43
+ const diffX = e.clientX - startX;
44
+ const newWidth = startWidth + diffX;
45
+
46
+ if (newWidth >= minWidth && newWidth <= maxWidth) {
47
+ listPane.style.flex = `0 0 ${newWidth}px`;
48
+ }
49
+ };
50
+
51
+ const onUp = () => {
52
+ separator.classList.remove('dragging');
53
+ document.body.style.userSelect = '';
54
+
55
+ document.removeEventListener('pointermove', onMove);
56
+ document.removeEventListener('pointerup', onUp);
57
+ window.removeEventListener('blur', onUp);
58
+ };
59
+
60
+ document.addEventListener('pointermove', onMove);
61
+ document.addEventListener('pointerup', onUp);
62
+ window.addEventListener('blur', onUp);
63
+ });
64
+ }
65
+
66
+ function initMessageList() {
67
+ const mailListContainer = document.querySelector('[data-mail-list-container]');
68
+ const mailContainer = document.querySelector('[data-mail-container]');
69
+ if (!mailListContainer || !mailContainer) return;
70
+
71
+ const mailListItems = mailListContainer.querySelectorAll('[data-mail-list-item]');
72
+ if (!mailListItems.length) return;
73
+
74
+ const mailErrorMessage = mailListContainer.querySelector('[data-mail-error-message]');
75
+
76
+ let currentRequestId = 0;
77
+
78
+ mailListContainer.addEventListener('click', (e) => {
79
+ const item = e.target.closest('[data-mail-list-item]');
80
+ if (!item) return;
81
+ if (e.target.closest('[data-mail-list-item-delete]')) return;
82
+
83
+ const messagePath = item.dataset.messagePath;
84
+ if (!messagePath) return;
85
+
86
+ mailListItems.forEach(i => i.classList.remove('active'));
87
+ item.classList.add('active');
88
+
89
+ fetchMessageDetail(messagePath);
90
+ });
91
+
92
+ mailContainer.addEventListener('click', (e) => {
93
+ const handle = e.target.closest('[data-mail-view-handle]');
94
+ if (!handle) return;
95
+
96
+ activateMailView(handle);
97
+ });
98
+
99
+ const firstMailListItem = mailListItems[0];
100
+ if (firstMailListItem) {
101
+ firstMailListItem.classList.add('active');
102
+ fetchMessageDetail(firstMailListItem.dataset.messagePath);
103
+ }
104
+
105
+ function fetchMessageDetail(url) {
106
+ const requestId = ++currentRequestId;
107
+
108
+ fetch(url, {
109
+ method: 'GET',
110
+ headers: {
111
+ 'Accept': 'text/html'
112
+ }
113
+ })
114
+ .then(response => {
115
+ if (!response.ok) throw new Error('Failed to load message');
116
+ return response.text();
117
+ })
118
+ .then(html => {
119
+ if (requestId !== currentRequestId) return;
120
+
121
+ // Extract the main content div from the response
122
+ const parser = new DOMParser();
123
+ const doc = parser.parseFromString(html, 'text/html');
124
+ const messageContent = doc.querySelector('[data-mail-detail-root]')
125
+
126
+ if (messageContent) {
127
+ mailContainer.innerHTML = messageContent.innerHTML;
128
+ const firstHandle = mailContainer.querySelector('[data-mail-view-handle]')
129
+ firstHandle && activateMailView(firstHandle);
130
+ }
131
+ })
132
+ .catch(error => {
133
+ console.error('Error loading message:', error);
134
+ if (mailErrorMessage) {
135
+ mailContainer.innerHTML = mailErrorMessage.innerHTML;
136
+ }
137
+ });
138
+ }
139
+
140
+ function activateMailView(handle) {
141
+ mailContainer.querySelectorAll('[data-mail-view-handle]').forEach(i => i.classList.remove('active'));
142
+ handle.classList.add('active');
143
+ mailContainer.querySelectorAll('[data-mail-view]').forEach((v) => {
144
+ const active = (v.dataset.mailView === handle.dataset.mailViewTarget);
145
+ v.classList.toggle('active', active);
146
+ if (active) {
147
+ const iframe = v.querySelector('iframe');
148
+ if (iframe && !iframe.src && iframe.dataset.src) {
149
+ iframe.src = iframe.dataset.src;
150
+ }
151
+ }
152
+ });
153
+ }
154
+ }
155
+ </script>
@@ -0,0 +1,281 @@
1
+ <style>
2
+ .message-header-container {
3
+ display: flex;
4
+ flex-direction: column;
5
+ }
6
+
7
+ .message-header {
8
+ display: grid;
9
+ grid-template-columns: auto 1fr;
10
+ gap: 0 1rem;
11
+ border: 1px solid #dee2e6;
12
+ border-radius: 0.25rem;
13
+ padding: 1rem;
14
+ background-color: #f8f9fa;
15
+ }
16
+
17
+ .message-header dl {
18
+ margin: 0;
19
+ display: contents;
20
+ }
21
+
22
+ .message-header small {
23
+ grid-column: 1 / -1;
24
+ margin-top: 0.5rem;
25
+ color: #6c757d;
26
+ }
27
+
28
+ .message-subject {
29
+ margin-top: 0;
30
+ }
31
+
32
+ /* Message Views Tabs */
33
+ .message-views-tabs-container {
34
+ display: flex;
35
+ flex-direction: column;
36
+ }
37
+
38
+ .message-views-tabs {
39
+ display: flex;
40
+ gap: 0.5rem;
41
+ margin: 1rem 0;
42
+ border-bottom: 2px solid #e0e0e0;
43
+ padding-bottom: 0;
44
+ }
45
+
46
+ .message-view-tab {
47
+ padding: 0.5rem 1rem;
48
+ background: none;
49
+ border: none;
50
+ border-bottom: 3px solid transparent;
51
+ cursor: pointer;
52
+ font-size: 0.9rem;
53
+ color: #666;
54
+ transition: color 0.2s, border-color 0.2s;
55
+ }
56
+
57
+ .message-view-tab:hover {
58
+ color: #333;
59
+ }
60
+
61
+ .message-view-tab.active {
62
+ color: #0066cc;
63
+ border-bottom-color: #0066cc;
64
+ }
65
+
66
+ .message-view-item {
67
+ display: none;
68
+ flex-direction: column;
69
+ flex: 1;
70
+ }
71
+
72
+ .message-view-item.active {
73
+ display: flex;
74
+ }
75
+
76
+ .message-view-item iframe {
77
+ width: 100%;
78
+ min-height: 400px;
79
+ border: 1px solid #e0e0e0;
80
+ border-radius: 0.25rem;
81
+ display: flex;
82
+ flex-direction: column;
83
+ flex: 1;
84
+ }
85
+
86
+ .message-attachments {
87
+ padding: 1rem .25rem 0;
88
+ font-size: 0.875rem;
89
+ }
90
+
91
+ .message-attachments a {
92
+ display: inline-block;
93
+ margin-right: 1rem;
94
+ margin-bottom: 0.5rem;
95
+ }
96
+
97
+ .message-header dt {
98
+ color: #495057;
99
+ grid-column: 1;
100
+ font-size: 0.875rem;
101
+ }
102
+
103
+ .message-header dd {
104
+ margin: 0;
105
+ color: #212529;
106
+ grid-column: 2;
107
+ word-break: break-word;
108
+ }
109
+
110
+ .message-item-header {
111
+ display: grid;
112
+ grid-template-columns: auto 1fr;
113
+ gap: 0 0.5rem;
114
+ margin: 0.25rem 0;
115
+ font-size: 0.875rem;
116
+ }
117
+
118
+ .message-item-header dt {
119
+ display: inline;
120
+ margin: 0;
121
+ }
122
+
123
+ .message-item-header dd {
124
+ display: inline;
125
+ margin: 0;
126
+ }
127
+
128
+ .message-item-header small {
129
+ font-size: 0.75rem;
130
+ color: #999;
131
+ }
132
+
133
+ .messages-toolbar {
134
+ display: flex;
135
+ gap: 0.5rem;
136
+ }
137
+
138
+ .messages-toolbar button,
139
+ .messages-toolbar form {
140
+ display: inline-block;
141
+ }
142
+
143
+ .messages-toolbar button img, .messages-toolbar button svg {
144
+ width: 20px;
145
+ height: 20px;
146
+ }
147
+
148
+ .messages-container {
149
+ display: flex;
150
+ gap: 0;
151
+ overflow: hidden;
152
+ }
153
+
154
+ .pane-separator {
155
+ flex: 0 0 8px;
156
+ cursor: col-resize;
157
+ user-select: none;
158
+ transition: background-color 0.2s;
159
+ }
160
+
161
+ .pane-separator:hover {
162
+ background-color: #a0a0a0;
163
+ }
164
+
165
+ .pane-separator.dragging {
166
+ background-color: #a0a0a0;
167
+ }
168
+
169
+ .messages-list-pane {
170
+ flex: 0 0 30%;
171
+ display: flex;
172
+ flex-direction: column;
173
+ height: calc(100vh - 2rem);
174
+ overflow: hidden;
175
+ min-height: 0;
176
+ background-color: #fafafa;
177
+ }
178
+
179
+ .messages-list-pane header {
180
+ margin: 1rem 1rem .5rem;
181
+ position: sticky;
182
+ top: 0;
183
+ z-index: 1;
184
+ background-color: #fafafa;
185
+ }
186
+
187
+ .messages-list-pane header h1 {
188
+ margin: 0 0 .5rem;
189
+ font-size: 1.25rem;
190
+ font-weight: 600;
191
+ }
192
+
193
+ .messages-list-pane header h1 a {
194
+ color: inherit;
195
+ text-decoration: none;
196
+ }
197
+
198
+ .messages-list-pane header h1 a:hover,
199
+ .messages-list-pane header h1 a:focus {
200
+ text-decoration: none;
201
+ color: #333;
202
+ opacity: 0.8;
203
+ }
204
+
205
+ .messages-list-pane ul {
206
+ list-style: none;
207
+ padding: 0;
208
+ margin: 0;
209
+ overflow-y: auto;
210
+ flex: 1;
211
+ min-height: 0;
212
+ }
213
+
214
+ .messages-list-pane li {
215
+ border-bottom: 1px solid #e0e0e0;
216
+ padding: 1rem;
217
+ cursor: pointer;
218
+ display: flex;
219
+ justify-content: space-between;
220
+ align-items: flex-start;
221
+ gap: 0.5rem;
222
+ }
223
+
224
+ .messages-list-pane li:hover {
225
+ background-color: #f0f0f0;
226
+ }
227
+
228
+ .messages-list-pane li.active {
229
+ background-color: #e3f2fd;
230
+ }
231
+
232
+ .message-item-content {
233
+ flex: 1;
234
+ min-width: 0;
235
+ }
236
+
237
+ .message-item-content p {
238
+ margin: 0 0 0.5rem 0;
239
+ font-weight: 500;
240
+ overflow: hidden;
241
+ text-overflow: ellipsis;
242
+ white-space: nowrap;
243
+ }
244
+
245
+ .message-item-content dl {
246
+ margin: 0.25rem 0;
247
+ font-size: 0.875rem;
248
+ }
249
+
250
+ .message-item-content dt,
251
+ .message-item-content dd {
252
+ display: inline;
253
+ margin: 0;
254
+ }
255
+
256
+ .message-item-content dt::after {
257
+ content: " ";
258
+ }
259
+
260
+ .message-item-content dd::after {
261
+ content: "\A";
262
+ white-space: pre;
263
+ }
264
+
265
+ .message-item-content span {
266
+ font-size: 0.75rem;
267
+ color: #999;
268
+ }
269
+
270
+ .message-item-delete-button {
271
+ font-size: 1rem;
272
+ }
273
+
274
+ .messages-detail-pane {
275
+ display: flex;
276
+ flex-direction: column;
277
+ flex: 1;
278
+ padding: 1rem;
279
+ background-color: white;
280
+ }
281
+ </style>
@@ -0,0 +1,82 @@
1
+ <h2 class="message-subject"><%= object.subject %></h2>
2
+
3
+ <div class="message-header-container">
4
+ <div class="message-header">
5
+ <dl>
6
+ <% if object.from %>
7
+ <dt>From:</dt>
8
+ <dd><%= object.from %></dd>
9
+ <% end %>
10
+
11
+ <% if object.sender %>
12
+ <dt>Sender:</dt>
13
+ <dd><%= object.sender %></dd>
14
+ <% end %>
15
+
16
+ <% if object.to %>
17
+ <dt>To:</dt>
18
+ <dd><%= object.to %></dd>
19
+ <% end %>
20
+
21
+ <% if object.cc %>
22
+ <dt>Cc:</dt>
23
+ <dd><%= object.cc %></dd>
24
+ <% end %>
25
+
26
+ <% if object.bcc %>
27
+ <dt>Bcc:</dt>
28
+ <dd><%= object.bcc %></dd>
29
+ <% end %>
30
+
31
+ <% if object.reply_to %>
32
+ <dt>Reply-To:</dt>
33
+ <dd><%= object.reply_to %></dd>
34
+ <% end %>
35
+ </dl>
36
+
37
+ <small><%= l object.created_at, format: :long %></small>
38
+ </div>
39
+ </div>
40
+
41
+ <% mail = object.mail %>
42
+
43
+ <% if mail.attachments.any? %>
44
+ <div class="message-attachments">
45
+ <% mail.attachments.each.with_index do |attachment, index| %>
46
+ <%= link_to attachment.filename, message_attachment_path(object, index), download: true %>
47
+ <% end %>
48
+ </div>
49
+ <% end %>
50
+
51
+ <% has_html = mail.html_part.present? %>
52
+ <% has_text = mail.text_part.present? %>
53
+
54
+ <div class="message-views-tabs-container">
55
+ <div class="message-views-tabs">
56
+ <% if has_html %>
57
+ <button class="message-view-tab" data-mail-view-handle data-mail-view-target="html">Html</button>
58
+ <button class="message-view-tab" data-mail-view-handle data-mail-view-target="source">Html Source</button>
59
+ <% end %>
60
+ <% if has_text %>
61
+ <button class="message-view-tab" data-mail-view-handle data-mail-view-target="text">Text</button>
62
+ <% end %>
63
+ <button class="message-view-tab" data-mail-view-handle data-mail-view-target="raw">Raw</button>
64
+ </div>
65
+ </div>
66
+
67
+ <% if has_html %>
68
+ <div class="message-view-item" data-mail-view="html">
69
+ <%= tag.iframe sealmess: "sealmess", data: { src: message_html_path(object) } %>
70
+ </div>
71
+ <div class="message-view-item" data-mail-view="source">
72
+ <%= tag.iframe sealmess: "sealmess", data: { src: message_source_path(object) } %>
73
+ </div>
74
+ <% end %>
75
+ <% if has_text %>
76
+ <div class="message-view-item" data-mail-view="text">
77
+ <%= tag.iframe sealmess: "sealmess", data: { src: message_text_path(object) } %>
78
+ </div>
79
+ <% end %>
80
+ <div class="message-view-item" data-mail-view="raw">
81
+ <%= tag.iframe sealmess: "sealmess", data: { src: message_raw_path(object) } %>
82
+ </div>
@@ -0,0 +1,71 @@
1
+ <template data-mail-error-message>
2
+ <%= t("sunabamail.failed_to_load_email") %>
3
+ </template>
4
+
5
+ <div class="messages-container" data-messages-list-container>
6
+ <!-- Left Pane: Messages List -->
7
+ <div class="messages-list-pane" data-messages-list-pane>
8
+ <header>
9
+ <h1><%= link_to "Sunabamail", root_path %></h1>
10
+ <div class="messages-toolbar">
11
+ <%= button_to messages_path, method: :get, form: { style: "display: inline;" } do %>
12
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
13
+ <path d="M4 12a8 8 0 0 1 13.66-5.66"/>
14
+ <polyline points="18 3 18 8 13 8"/>
15
+ <path d="M20 12a8 8 0 0 1-13.66 5.66"/>
16
+ <polyline points="6 21 6 16 11 16"/>
17
+ </svg>
18
+ <% end %>
19
+ <%= button_to messages_all_path, method: :delete,
20
+ disabled: @messages.blank?,
21
+ form: { style: "display: inline;" },
22
+ data: { turbo_confirm: t("sunabamail.destroy_confirm") } do %>
23
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
24
+ <path d="M3 6h18"/>
25
+ <path d="M8 6V4h8v2"/>
26
+ <path d="M6 6l1 14h10l1-14"/>
27
+ <path d="M10 11v6"/>
28
+ <path d="M14 11v6"/>
29
+ </svg>
30
+ <% end %>
31
+ </div>
32
+ </header>
33
+
34
+ <ul id="messages" data-mail-list-container>
35
+ <p style="color: green"><%= notice %></p>
36
+
37
+ <% @messages.each do |message| %>
38
+ <li class="message-item" data-mail-list-item data-message-path="<%= message_path(message) %>">
39
+ <div class="message-item-content">
40
+ <p><%= message.subject %></p>
41
+ <dl class="message-item-header">
42
+ <% if message.from %>
43
+ <dt>From:</dt>
44
+ <dd><%= message.from %></dd>
45
+ <% end %>
46
+ <% if message.to %>
47
+ <dt>To:</dt>
48
+ <dd><%= message.to %></dd>
49
+ <% end %>
50
+ </dl>
51
+ <span><%= time_ago_in_words message.created_at %></span>
52
+ </div>
53
+ <%= button_to message_path(message), method: :delete,
54
+ class: "message-item-delete-button",
55
+ data: { turbo_confirm: t("sunabamail.destroy_confirm") },
56
+ form: { data: { mail_list_item_delete: true } } do %>
57
+ &times;
58
+ <% end %>
59
+ </li>
60
+ <% end %>
61
+ </ul>
62
+ </div>
63
+
64
+ <!-- Resizable Separator -->
65
+ <div class="pane-separator" data-pane-separator></div>
66
+
67
+ <!-- Right Pane: Message Detail -->
68
+ <div class="messages-detail-pane" data-mail-container>
69
+ <p><%= t("sunabamail.no_mail_items") %></p>
70
+ </div>
71
+ </div>
@@ -0,0 +1,3 @@
1
+ <div data-mail-detail-root>
2
+ <%= render "message", object: @message %>
3
+ </div>
Binary file
@@ -0,0 +1,5 @@
1
+ en:
2
+ sunabamail:
3
+ destroy_confirm: "Are you sure?"
4
+ failed_to_load_email: "Failed to load email."
5
+ no_mail_items: "No mail items."
data/config/routes.rb ADDED
@@ -0,0 +1,16 @@
1
+ Sunabamail::Engine.routes.draw do
2
+ namespace :messages do
3
+ resource :all, only: %i[destroy]
4
+ end
5
+ resources :messages, only: %i[index show destroy] do
6
+ scope module: :messages do
7
+ resources :attachments, only: %i[show]
8
+ resource :html, only: %i[show]
9
+ resource :raw, only: %i[show]
10
+ resource :source, only: %i[show]
11
+ resource :text, only: %i[show]
12
+ end
13
+ end
14
+
15
+ root to: redirect("messages")
16
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Sunabamail::InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_files
7
+ template "db/sunabamail_schema.rb"
8
+ end
9
+
10
+ def configure_adapter
11
+ pathname = Pathname(destination_root).join("config/environments/development.rb")
12
+
13
+ if File.read(pathname).match?(/config\.action_mailer\.delivery_method\s*=/)
14
+ gsub_file pathname,
15
+ /(# )?config\.action_mailer\.delivery_method\s+=.*\n/,
16
+ "config.action_mailer.delivery_method = :sunabamail\n"
17
+ else
18
+ environment <<~RUBY, env: "development"
19
+ config.action_mailer.delivery_method = :sunabamail
20
+ RUBY
21
+ end
22
+ end
23
+
24
+ def configure_database
25
+ environment <<~RUBY, env: "development"
26
+ config.sunabamail.connects_to = { database: { writing: :sunabamail } }
27
+ RUBY
28
+ end
29
+
30
+ def add_mount_routes
31
+ route <<~ROUTE
32
+ if Rails.configuration.action_mailer.delivery_method == :sunabamail
33
+ mount Sunabamail::Engine => "/sunabamail"
34
+ end
35
+
36
+ ROUTE
37
+ end
38
+
39
+ def database_configuration_hint
40
+ say ""
41
+ say "#{set_color('✔', :green)} Sunabamail setup complete"
42
+ say ""
43
+
44
+ say "Changes applied:", :blue
45
+ say
46
+ say " • Updated config/environments/development.rb"
47
+ say " - action_mailer.delivery_method = :sunabamail"
48
+ say " - config.sunabamail.connects_to = { database: { writing: :sunabamail } }"
49
+ say " • Updated config/routes.rb"
50
+ say " - Mounted Sunabamail::Engine at /sunabamail (when delivery_method is :sunabamail)"
51
+ say ""
52
+
53
+ say "Next steps:", :blue
54
+ say
55
+
56
+ say "1. Update config/database.yml:", :bold
57
+ say ""
58
+ say " # if you're SQLite, it'll look like this:"
59
+ say ""
60
+ say " development:"
61
+ say " primary:"
62
+ say " <<: *default"
63
+ say " database: storage/development.sqlite3"
64
+ say " sunabamail:"
65
+ say " <<: *default"
66
+ say " database: storage/development_sunabamail.sqlite3"
67
+ say " migrations_paths: db/sunabamail_migrate"
68
+ say ""
69
+ say " # ...or if you're using MySQL/PostgreSQL/Trilogy:"
70
+ say ""
71
+ say " development:"
72
+ say " primary: &primary_development"
73
+ say " <<: *default"
74
+ say " database: app_development"
75
+ say " username: app"
76
+ say " password: <%= ENV[\"APP_DATABASE_PASSWORD\"] %>"
77
+ say " sunabamail:"
78
+ say " <<: *primary_development"
79
+ say " database: app_development_sunabamail"
80
+ say " migrations_paths: db/sunabamail_migrate"
81
+ say ""
82
+ say "2. Run:"
83
+ say ""
84
+ say " $ rails db:prepare"
85
+ say ""
86
+ end
87
+ end
@@ -0,0 +1,23 @@
1
+ ActiveRecord::Schema[7.1].define(version: 1) do
2
+ create_table "sunabamail_message_raws", id: :bigint, force: :cascade do |t|
3
+ t.datetime "created_at", null: false
4
+ t.text "encoded", null: false
5
+ t.bigint "sunabamail_message_id", null: false
6
+ t.datetime "updated_at", null: false
7
+ t.index ["sunabamail_message_id"], name: "index_sunabamail_message_raws_on_sunabamail_message_id", unique: true
8
+ end
9
+
10
+ create_table "sunabamail_messages", id: :bigint, force: :cascade do |t|
11
+ t.string "bcc"
12
+ t.string "cc"
13
+ t.datetime "created_at", null: false
14
+ t.string "from"
15
+ t.string "reply_to"
16
+ t.string "sender"
17
+ t.string "subject"
18
+ t.string "to"
19
+ t.datetime "updated_at", null: false
20
+ end
21
+
22
+ add_foreign_key "sunabamail_message_raws", "sunabamail_messages"
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunabamail
4
+ class DeliveryMethod
5
+ def initialize(options = {})
6
+ end
7
+
8
+ def deliver!(mail)
9
+ subject = mail.subject
10
+ created_at = mail.date || Time.zone.now
11
+ updated_at = created_at
12
+ addresses = %i[bcc cc from reply_to sender to].to_h { [ _1, mail[_1].to_s.presence ] }
13
+
14
+ message = Sunabamail::Message.new(**addresses, subject:, created_at:, updated_at:)
15
+ message.build_raw(encoded: mail.encoded)
16
+ message.save!
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunabamail
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Sunabamail
6
+
7
+ rake_tasks do
8
+ load "sunabamail/tasks.rb"
9
+ end
10
+
11
+ config.sunabamail = ActiveSupport::OrderedOptions.new
12
+
13
+ initializer "sunabamail.config" do
14
+ Sunabamail.connects_to = { database: { writing: :sunabamail } }
15
+
16
+ config.sunabamail.each do |name, value|
17
+ Sunabamail.public_send("#{name}=", value)
18
+ end
19
+ end
20
+
21
+ initializer "sunabamail.add_delivery_method" do
22
+ ActiveSupport.on_load :action_mailer do
23
+ ActionMailer::Base.add_delivery_method(:sunabamail, Sunabamail::DeliveryMethod)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,6 @@
1
+ namespace :sunabamail do
2
+ desc "Install Sunabamail"
3
+ task :install do
4
+ Rails::Command.invoke :generate, [ "sunabamail:install" ]
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sunabamail
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sunabamail.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sunabamail/version"
4
+
5
+ require "zeitwerk"
6
+
7
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
8
+ loader.ignore("#{__dir__}/sunabamail/tasks.rb")
9
+ loader.ignore("#{__dir__}/generators")
10
+ loader.setup
11
+
12
+ module Sunabamail
13
+ extend self
14
+
15
+ attr_accessor :connects_to
16
+ end
17
+
18
+ begin
19
+ require "rails"
20
+ rescue LoadError
21
+ # do nothing.
22
+ end
23
+
24
+ if defined?(Rails::Engine)
25
+ require "sunabamail/engine"
26
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sunabamail
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - hamajyotan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionmailer
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activerecord
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ description: Provides a custom Action Mailer delivery method that stores outgoing
55
+ emails in the database instead of sending them. Stored emails can be viewed through
56
+ a dedicated interface, making it useful for development and staging environments.
57
+ email:
58
+ - hamajyotan@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".github/workflows/main.yml"
64
+ - ".rubocop.yml"
65
+ - CHANGELOG.md
66
+ - LICENSE.txt
67
+ - README.md
68
+ - Rakefile
69
+ - app/controllers/sunabamail/application_controller.rb
70
+ - app/controllers/sunabamail/messages/alls_controller.rb
71
+ - app/controllers/sunabamail/messages/application_controller.rb
72
+ - app/controllers/sunabamail/messages/attachments_controller.rb
73
+ - app/controllers/sunabamail/messages/htmls_controller.rb
74
+ - app/controllers/sunabamail/messages/raws_controller.rb
75
+ - app/controllers/sunabamail/messages/sources_controller.rb
76
+ - app/controllers/sunabamail/messages/texts_controller.rb
77
+ - app/controllers/sunabamail/messages_controller.rb
78
+ - app/models/sunabamail/message.rb
79
+ - app/models/sunabamail/message_raw.rb
80
+ - app/models/sunabamail/record.rb
81
+ - app/views/layouts/sunabamail/application.html.erb
82
+ - app/views/sunabamail/application/_scripts.html.erb
83
+ - app/views/sunabamail/application/_styles.html.erb
84
+ - app/views/sunabamail/messages/_message.html.erb
85
+ - app/views/sunabamail/messages/index.html.erb
86
+ - app/views/sunabamail/messages/show.html.erb
87
+ - assets/sunabamail.png
88
+ - config/locales/en.yml
89
+ - config/routes.rb
90
+ - lib/generators/sunabamail/install_generator.rb
91
+ - lib/generators/sunabamail/templates/db/sunabamail_schema.rb
92
+ - lib/sunabamail.rb
93
+ - lib/sunabamail/delivery_method.rb
94
+ - lib/sunabamail/engine.rb
95
+ - lib/sunabamail/tasks.rb
96
+ - lib/sunabamail/version.rb
97
+ homepage: https://github.com/hamajyotan/sunabamail
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ homepage_uri: https://github.com/hamajyotan/sunabamail
102
+ source_code_uri: https://github.com/hamajyotan/sunabamail
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 3.1.0
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 4.0.3
118
+ specification_version: 4
119
+ summary: A drop-in Action Mailer delivery method that stores emails in the database
120
+ instead of sending them.
121
+ test_files: []