rails_mail 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +170 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/images/rails_mail/rails-mail.png +0 -0
  6. data/app/channels/rails_mail/application_cable/channel.rb +6 -0
  7. data/app/channels/rails_mail/application_cable/connection.rb +17 -0
  8. data/app/channels/rails_mail/emails_channel.rb +7 -0
  9. data/app/controllers/rails_mail/base_controller.rb +13 -0
  10. data/app/controllers/rails_mail/emails_controller.rb +40 -0
  11. data/app/controllers/rails_mail/frontends_controller.rb +51 -0
  12. data/app/frontend/rails_mail/application.js +15 -0
  13. data/app/frontend/rails_mail/modules/auto_submit.js +30 -0
  14. data/app/frontend/rails_mail/modules/consumer.js +3 -0
  15. data/app/frontend/rails_mail/modules/email_highlight_controller.js +48 -0
  16. data/app/frontend/rails_mail/modules/emails_channel.js +12 -0
  17. data/app/frontend/rails_mail/modules/timeago.js +41 -0
  18. data/app/frontend/rails_mail/style.css +0 -0
  19. data/app/frontend/rails_mail/vendor/action_cable.js +512 -0
  20. data/app/frontend/rails_mail/vendor/date-fns.js +8 -0
  21. data/app/frontend/rails_mail/vendor/stimulus.js +2408 -0
  22. data/app/frontend/rails_mail/vendor/tailwind/tailwind.min.js +83 -0
  23. data/app/frontend/rails_mail/vendor/turbo.js +6697 -0
  24. data/app/helpers/rails_mail/emails_helper.rb +4 -0
  25. data/app/helpers/rails_mail/turbo_helper.rb +41 -0
  26. data/app/jobs/rails_mail/trim_emails_job.rb +40 -0
  27. data/app/models/rails_mail/email.rb +45 -0
  28. data/app/views/layouts/rails_mail/application.html.erb +72 -0
  29. data/app/views/rails_mail/emails/_email.html.erb +7 -0
  30. data/app/views/rails_mail/emails/_empty_state.html.erb +5 -0
  31. data/app/views/rails_mail/emails/_form.html.erb +22 -0
  32. data/app/views/rails_mail/emails/_show.html.erb +50 -0
  33. data/app/views/rails_mail/emails/edit.html.erb +12 -0
  34. data/app/views/rails_mail/emails/index.html.erb +16 -0
  35. data/app/views/rails_mail/emails/index.turbo_stream.erb +7 -0
  36. data/app/views/rails_mail/emails/new.html.erb +11 -0
  37. data/app/views/rails_mail/emails/show.html.erb +43 -0
  38. data/app/views/rails_mail/shared/_email.html.erb +25 -0
  39. data/app/views/rails_mail/shared/_email_sidebar.html.erb +9 -0
  40. data/config/routes.rb +13 -0
  41. data/db/migrate/20250118201227_create_rails_mail_emails.rb +9 -0
  42. data/lib/generators/rails_mail/install/install_generator.rb +15 -0
  43. data/lib/generators/rails_mail/install/templates/initializer.rb +23 -0
  44. data/lib/rails_mail/configuration.rb +35 -0
  45. data/lib/rails_mail/delivery_method.rb +22 -0
  46. data/lib/rails_mail/engine.rb +11 -0
  47. data/lib/rails_mail/version.rb +3 -0
  48. data/lib/rails_mail.rb +40 -0
  49. data/lib/tasks/rails_mail_tasks.rake +10 -0
  50. metadata +132 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f203c591ad81cc5cc52b5aa4b9f88f7ba4cb54bda733f7f67650229cf5e4dc73
4
+ data.tar.gz: 6522e79faa67890b74d33a1a0529085627dbd0630d028b461e6af78143e80be5
5
+ SHA512:
6
+ metadata.gz: 385b93bbbed99ad5f1909ddc87b3fdfccc7e4563d810c912bff197a9780abd62c07bbec4d426a30a3ce7967a5f5894e85b3236018d5e9ebafb9a065b28a38e90
7
+ data.tar.gz: 0da762085610c6dc626763b1610133fd826e915e15a20d329c924bc301763f969b0f6457b470fab597953ff368dbb964ff044f82873487e6250b03c90031148b
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Peter Philips
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # RailsMail
2
+ RailsMail is a Rails engine that provides a database-backed delivery method for Action Mailer, primarily intended for local development and staging environments. It captures emails sent through your Rails application and provides a web interface to view them.
3
+
4
+ RailsMail saves all outgoing emails to your database instead of actually sending them. This is particularly useful for:
5
+ - Local development to inspect emails without setting up a real mail server
6
+ - Staging environments where you want to prevent actual email delivery
7
+ - Testing email templates and layouts
8
+
9
+ ![rails mail screenshot](rails-mail-demo.png)
10
+
11
+ ### Features
12
+ * Implements delivery_method for ActionMailer to catch emails and store them in the database
13
+ * Real-time updates using Turbo and ActionCable
14
+ * Search functionality across email fields (subject, from, to, cc, bcc)
15
+ * Clean, responsive UI for viewing email contents
16
+ * Optional authentication support
17
+ * Trimming emails older than a specified duration or a maximum number of emails
18
+ * Ability to manually clear emails out and turn on/off that functionality based on environment (eg, so that in Staging, other stakeholders can't clear emails out, but in dev sometimes you want a clean slate)
19
+ * Dynamic time ago in words using date-fns
20
+
21
+ ## Installation
22
+
23
+ To install RailsMail, follow these steps:
24
+
25
+ 1. **Add the gem to your application's Gemfile:**
26
+
27
+ ```ruby
28
+ gem "rails_mail"
29
+ ```
30
+
31
+ 2. **Run `bundle install` to install the gem:**
32
+
33
+ ```bash
34
+ $ bundle install
35
+ ```
36
+
37
+ 3. **Generate the necessary database migration:**
38
+
39
+ Run the following command to create the migration for storing emails:
40
+
41
+ ```bash
42
+ $ rake rails_mail:install:migrations
43
+ ```
44
+
45
+ 4. **Run the migration:**
46
+
47
+ Apply the migration to your database:
48
+
49
+ ```bash
50
+ $ rails db:migrate
51
+ ```
52
+
53
+ 5. **Start your Rails server and access the RailsMail interface:**
54
+
55
+ Visit `http://localhost:3000/rails_mail` to view captured emails.
56
+
57
+
58
+ ## Usage
59
+
60
+ To use RailsMail in your application:
61
+
62
+ 1. **Configure Action Mailer to use RailsMail as the delivery method:**
63
+
64
+ Add the following configuration to your `config/environments/development.rb` (or `staging.rb`):
65
+
66
+ ```ruby
67
+ config.action_mailer.delivery_method = :rails_mail
68
+ ```
69
+
70
+ 2. **Mount the engine in your routes:**
71
+
72
+ Add the following line to your `config/routes.rb`:
73
+
74
+ ```ruby
75
+ mount RailsMail::Engine => "/rails_mail"
76
+ ```
77
+
78
+ 3. **Configure the initializer**
79
+ See the [Configuration](#configuration) section for more details.
80
+
81
+ 4. **Visit `/rails_mail` in your browser to view all captured emails.**
82
+
83
+ ### Configuration
84
+
85
+ RailsMail can be configured through an initializer:
86
+
87
+ ```ruby
88
+ # config/initializers/rails_mail.rb
89
+ RailsMail.configure do |config|
90
+ # Optional authentication callback
91
+ # (if using Authlogic. If using Devise see the Authentication section)
92
+ config.authentication_callback do
93
+ user_session = UserSession.find
94
+ raise ActionController::RoutingError.new('Not Found') unless user_session&.user&.admin?
95
+ end
96
+
97
+ # Delete emails older than the specified duration
98
+ config.trim_emails_older_than = 30.days
99
+
100
+ # Keep only the most recent N emails
101
+ config.trim_emails_max_count = 1000
102
+
103
+ # Control whether trimming runs synchronously (:now) or asynchronously (:later)
104
+ config.sync_via = :later
105
+ end
106
+ ```
107
+
108
+ ### Configuration Options
109
+
110
+ - `authentication_callback`: A block that will be called before accessing RailsMail routes
111
+ - `trim_emails_older_than`: Accepts an ActiveSupport::Duration object (e.g., 30.days). Emails older than this duration will be deleted.
112
+ - `trim_emails_max_count`: Keeps only the N most recent emails, deleting older ones.
113
+ - `sync_via`: Controls whether the trimming job runs synchronously (:now) or asynchronously (:later)
114
+
115
+ ## Authentication
116
+ Authentication is optional, but recommended and will depend on your application's authentication setup. This gem provides an `authentication_callback` that you can configure in the initializer which is helpful for Authlogic. If you are using Devise, you can simply wrap the mount point of the engine.
117
+
118
+ ### Authlogic
119
+
120
+ If you're using Authlogic, configure the authentication in the initializer:
121
+ ```ruby
122
+ # config/initializers/rails_mail.rb
123
+ RailsMail.configure do |config|
124
+ config.authentication_callback do
125
+ user_session = UserSession.find
126
+ # Provide a more helpful message in development
127
+ msg = Rails.env.development? ? 'Forbidden - make sure you have the correct permission in config/initializers/rails_mail.rb' : 'Not Found'
128
+ raise ActionController::RoutingError.new(msg) unless user_session&.user&.admin?
129
+ end
130
+ end
131
+ end
132
+ ```
133
+
134
+ ### Devise
135
+
136
+ If you're using Devise, you can simply wrap the mount point of the engine using Devise's `authenticate` route helper.
137
+
138
+ ```ruby
139
+ # config/routes.rb
140
+ authenticate :user, ->(user) { user.admin? } do
141
+ mount RailsMail::Engine => "/rails_mail"
142
+ end
143
+ ```
144
+
145
+ ## Real-time updates
146
+
147
+ RailsMail uses Turbo, TurboStreams, and ActionCable to provide real-time updates in the ui when emails are delivered. When you send an email, the new email will be displayed in the list of emails. There may be gotchas depending on your setup and environment.
148
+
149
+ ## Gotchas
150
+
151
+ - In development environment, the typical default for ActionCable (cable.yml) is to use the async adapter which is an in-memory adapter. If you try to send an email from the rails console, it will not auto-update the ui. You can change the adapter to the development adapter by running `cable.yml` to use something like the redis, postgresql adapter, or solidcable.
152
+ - In staging environments, the same idea typically applies that you need to use a multi-process adapter like redis, postgresql, or solidcable.
153
+
154
+ ## Future work / ideas
155
+
156
+ - Implement infinite scroll rather than loading all emails at once
157
+ - Implement adapters to support real-time updates without ActionCable (polling or SSE)
158
+ - Implement attachments support
159
+ - Implement introspection of application notifiers and allow manual delivery/inspection of emails
160
+ - Need to introspect the arguments of the notifier and see if the arguments can be paired with active record models or to allow a mapping of argument type to sample data / fixtures.
161
+ - Implement read/unread functionality
162
+ - Implement individual email delete
163
+ - Implement multi-part (text/html) email support
164
+
165
+ ## Contributing
166
+ Contribution directions go here.
167
+
168
+ ## License
169
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
170
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,6 @@
1
+ module RailsMail
2
+ module ApplicationCable
3
+ class Channel < ActionCable::Channel::Base
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ module RailsMail
2
+ module ApplicationCable
3
+ class Connection < ActionCable::Connection::Base
4
+ # identified_by :current_user
5
+
6
+ def connect
7
+ # self.current_user = find_verified_user
8
+ end
9
+
10
+ private
11
+
12
+ def find_verified_user
13
+ # TODO: Implement user verification
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,7 @@
1
+ module RailsMail
2
+ class EmailsChannel < ApplicationCable::Channel
3
+ def subscribed
4
+ stream_from "rails_mail:emails"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module RailsMail
2
+ class BaseController < ActionController::Base
3
+ layout "rails_mail/application"
4
+ helper TurboHelper if defined?(TurboHelper)
5
+ before_action :authenticate!
6
+
7
+ private
8
+
9
+ def authenticate!
10
+ instance_eval(&RailsMail.authentication_callback) if RailsMail.authentication_callback.kind_of?(Proc)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,40 @@
1
+ module RailsMail
2
+ class EmailsController < BaseController
3
+ # GET /emails
4
+ def index
5
+ @emails = Email.all
6
+ @emails = @emails.search(params[:q]) if params[:q].present?
7
+ @emails = @emails.order(created_at: :desc)
8
+ @email = params[:id] ? Email.find(params[:id]) : Email.last
9
+
10
+ respond_to do |format|
11
+ format.html
12
+ format.turbo_stream if params.key?(:q)
13
+ end
14
+ end
15
+
16
+ # GET /emails/1
17
+ def show
18
+ @emails = Email.order(created_at: :desc)
19
+ @email = Email.find(params[:id])
20
+ if request.headers["Turbo-Frame"]
21
+ render partial: "rails_mail/emails/show", locals: { email: @email }
22
+ else
23
+ render :index
24
+ end
25
+ end
26
+
27
+ def destroy_all
28
+ # Email.destroy_all
29
+ respond_to do |format|
30
+ format.html { redirect_to emails_path }
31
+ format.turbo_stream {
32
+ render turbo_stream: [
33
+ turbo_stream.update("email-sidebar", ""),
34
+ turbo_stream.update("email_content", partial: "empty_state")
35
+ ]
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,51 @@
1
+ module RailsMail
2
+ class FrontendsController < ActionController::Base # rubocop:disable Rails/ApplicationController
3
+ STATIC_ASSETS = {
4
+ css: {
5
+ # tailwind: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "tailwind", "tailwind.min.css"),
6
+ style: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "style.css")
7
+ },
8
+ js: {
9
+ tailwind: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "tailwind", "tailwind.min.js")
10
+ }
11
+ }.freeze
12
+
13
+ # Additional JS modules that don't live in app/frontend/rails_mail/modules
14
+ MODULE_OVERRIDES = {
15
+ application: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "application.js"),
16
+ stimulus: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "stimulus.js"),
17
+ turbo: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "turbo.js"),
18
+ action_cable: RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "action_cable.js"),
19
+ "date-fns": RailsMail::Engine.root.join("app", "frontend", "rails_mail", "vendor", "date-fns.js")
20
+ }.freeze
21
+
22
+ def self.js_modules
23
+ if Rails.env.production?
24
+ @_js_modules ||= load_js_modules
25
+ else
26
+ load_js_modules
27
+ end
28
+ end
29
+
30
+ def self.load_js_modules
31
+ RailsMail::Engine.root.join("app", "frontend", "rails_mail", "modules").children.select(&:file?).each_with_object({}) do |file, modules|
32
+ key = File.basename(file.basename.to_s, ".js").to_sym
33
+ modules[key] = file
34
+ end.merge(MODULE_OVERRIDES)
35
+ end
36
+
37
+ # Necessarly to serve Javascript to the browser
38
+ skip_after_action :verify_same_origin_request, raise: false
39
+ before_action { expires_in 1.year, public: true }
40
+
41
+ def static
42
+ render file: STATIC_ASSETS.dig(params[:format].to_sym, params[:name].to_sym) || raise(ActionController::RoutingError, "Not Found")
43
+ end
44
+
45
+ def module
46
+ raise(ActionController::RoutingError, "Not Found") if params[:format] != "js"
47
+
48
+ render file: self.class.js_modules[params[:name].to_sym] || raise(ActionController::RoutingError, "Not Found")
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ import { Application } from "stimulus";
2
+ import "turbo";
3
+ import EmailHighlightController from "email_highlight_controller";
4
+ import AutoSubmit from 'auto_submit'
5
+ import Timeago from 'timeago'
6
+
7
+ const application = Application.start();
8
+
9
+ application.register("email-highlight", EmailHighlightController);
10
+ application.register('auto-submit', AutoSubmit)
11
+ application.register('timeago', Timeago)
12
+
13
+ window.Stimulus = Application.start();
14
+
15
+ import "emails_channel";
@@ -0,0 +1,30 @@
1
+ import { Controller } from "stimulus";
2
+ function debounce(callback, delay) {
3
+ let timeout;
4
+ return (...args) => {
5
+ clearTimeout(timeout), timeout = setTimeout(() => {
6
+ callback.apply(this, args);
7
+ }, delay);
8
+ };
9
+ }
10
+ const _AutoSubmit = class _AutoSubmit extends Controller {
11
+ initialize() {
12
+ this.submit = this.submit.bind(this);
13
+ }
14
+ connect() {
15
+ this.delayValue > 0 && (this.submit = debounce(this.submit, this.delayValue));
16
+ }
17
+ submit() {
18
+ this.element.requestSubmit();
19
+ }
20
+ };
21
+ _AutoSubmit.values = {
22
+ delay: {
23
+ type: Number,
24
+ default: 150
25
+ }
26
+ };
27
+ let AutoSubmit = _AutoSubmit;
28
+ export {
29
+ AutoSubmit as default
30
+ };
@@ -0,0 +1,3 @@
1
+ import { createConsumer } from "action_cable"
2
+
3
+ export default createConsumer()
@@ -0,0 +1,48 @@
1
+ import { Controller } from "stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["link"]
5
+
6
+ // Define the Tailwind class as a static property
7
+ static activeClass = "bg-gray-100"
8
+
9
+ connect() {
10
+ console.log("EmailHighlightController connected")
11
+ // Listen for Turbo frame load events
12
+ document.addEventListener("turbo:frame-load", this.highlightCurrentEmail.bind(this))
13
+ }
14
+
15
+ disconnect() {
16
+ // Clean up event listener when the controller is disconnected
17
+ document.removeEventListener("turbo:frame-load", this.highlightCurrentEmail.bind(this))
18
+ }
19
+
20
+ // Called when a link is clicked
21
+ highlight(event) {
22
+ // Remove active class from all links
23
+ this.linkTargets.forEach(link => {
24
+ link.classList.remove(this.constructor.activeClass)
25
+ })
26
+
27
+ // Add active class to clicked link
28
+ const clickedLink = event.currentTarget
29
+ clickedLink.classList.add(this.constructor.activeClass)
30
+ }
31
+
32
+ highlightCurrentEmail() {
33
+ // Get the current email ID from the content frame
34
+ const emailContent = document.querySelector("#email_content [data-email-id]")
35
+ if (emailContent) {
36
+ const currentEmailId = emailContent.dataset.emailId
37
+
38
+ // Highlight the link with the matching email ID
39
+ this.linkTargets.forEach(link => {
40
+ if (link.dataset.emailId === currentEmailId) {
41
+ link.classList.add(this.constructor.activeClass)
42
+ } else {
43
+ link.classList.remove(this.constructor.activeClass)
44
+ }
45
+ })
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,12 @@
1
+ import consumer from "consumer" // Your Action Cable consumer setup
2
+ import { renderStreamMessage } from "turbo" // If you've got turbo.js available
3
+
4
+ consumer.subscriptions.create({
5
+ channel: "RailsMail::EmailsChannel"
6
+ }, {
7
+ received(data) {
8
+ console.log("received data on Emails Channel", data)
9
+ // data should be a string containing <turbo-stream> tags
10
+ renderStreamMessage(data)
11
+ }
12
+ })
@@ -0,0 +1,41 @@
1
+ import { Controller } from "stimulus";
2
+ import { formatDistanceToNow } from "date-fns";
3
+ const _Timeago = class _Timeago extends Controller {
4
+ initialize() {
5
+ this.isValid = !0;
6
+ }
7
+ connect() {
8
+ this.load(), this.hasRefreshIntervalValue && this.isValid && this.startRefreshing();
9
+ }
10
+ disconnect() {
11
+ this.stopRefreshing();
12
+ }
13
+ load() {
14
+ const datetime = this.datetimeValue, date = Date.parse(datetime), options = {
15
+ includeSeconds: this.includeSecondsValue,
16
+ addSuffix: this.addSuffixValue,
17
+ locale: this.locale
18
+ };
19
+ Number.isNaN(date) && (this.isValid = !1, console.error(
20
+ `[@stimulus-components/timeago] Value given in 'data-timeago-datetime' is not a valid date (${datetime}). Please provide a ISO 8601 compatible datetime string. Displaying given value instead.`
21
+ )), this.element.dateTime = datetime, this.element.innerHTML = this.isValid ? formatDistanceToNow(date, options) : datetime;
22
+ }
23
+ startRefreshing() {
24
+ this.refreshTimer = setInterval(() => {
25
+ this.load();
26
+ }, this.refreshIntervalValue);
27
+ }
28
+ stopRefreshing() {
29
+ this.refreshTimer && clearInterval(this.refreshTimer);
30
+ }
31
+ };
32
+ _Timeago.values = {
33
+ datetime: String,
34
+ refreshInterval: Number,
35
+ includeSeconds: Boolean,
36
+ addSuffix: Boolean
37
+ };
38
+ let Timeago = _Timeago;
39
+ export {
40
+ Timeago as default
41
+ };
File without changes