├── .rspec ├── lib ├── supermail │ └── version.rb ├── generators │ └── supermail │ │ ├── email │ │ ├── templates │ │ │ └── email.rb.tt │ │ └── email_generator.rb │ │ └── install │ │ ├── templates │ │ └── application_email.rb.tt │ │ └── install_generator.rb └── supermail.rb ├── sig └── supermail.rbs ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── Gemfile ├── .github └── workflows │ └── main.yml ├── spec ├── spec_helper.rb ├── generators │ ├── install_generator_spec.rb │ └── email_generator_spec.rb ├── supermail_spec.rb └── mailto_spec.rb ├── supermail.gemspec ├── README.md └── Gemfile.lock /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/supermail/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Supermail 4 | VERSION = "0.2.2" 5 | end 6 | -------------------------------------------------------------------------------- /sig/supermail.rbs: -------------------------------------------------------------------------------- 1 | module Supermail 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /lib/generators/supermail/email/templates/email.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class <%= class_name %> < ApplicationEmail 4 | def body = <<~PLAIN 5 | 6 | PLAIN 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in supermail.gemspec 6 | gemspec 7 | 8 | gem "irb" 9 | gem "rake", "~> 13.0" 10 | 11 | gem "rspec", "~> 3.0" 12 | -------------------------------------------------------------------------------- /lib/generators/supermail/install/templates/application_email.rb.tt: -------------------------------------------------------------------------------- 1 | class ApplicationEmail < Supermail::Rails::Base 2 | def from = "website@example.com" 3 | 4 | def body 5 | <<~_ 6 | #{yield if block_given?} 7 | 8 | Best, 9 | 10 | The Example.com Team 11 | _ 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "supermail" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /lib/generators/supermail/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/base' 4 | 5 | module Supermail 6 | class InstallGenerator < ::Rails::Generators::Base 7 | source_root File.expand_path('templates', __dir__) 8 | 9 | desc "Install Supermail in a Rails application" 10 | 11 | def create_application_email 12 | template 'application_email.rb', 'app/emails/application_email.rb' 13 | end 14 | 15 | def create_emails_directory 16 | empty_directory 'app/emails' 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '3.4.2' 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /lib/generators/supermail/email/email_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators/named_base' 4 | 5 | module Supermail 6 | class EmailGenerator < ::Rails::Generators::NamedBase 7 | source_root File.expand_path('templates', __dir__) 8 | 9 | desc "Generate a new email class" 10 | 11 | def create_email_file 12 | template 'email.rb', "app/emails/#{file_path}.rb" 13 | end 14 | 15 | private 16 | 17 | def file_path 18 | "#{base_name.underscore}_email" 19 | end 20 | 21 | def class_name 22 | "#{base_name.camelize}Email" 23 | end 24 | 25 | def base_name 26 | stripped = name.to_s.sub(/_?email\z/i, '') 27 | stripped.empty? ? name : stripped 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/all' 4 | require 'rails/generators/test_case' 5 | require 'minitest/assertions' 6 | require "supermail" 7 | 8 | RSpec.configure do |config| 9 | # Enable flags like --only-failures and --next-failure 10 | config.example_status_persistence_file_path = ".rspec_status" 11 | 12 | # Disable RSpec exposing methods globally on `Module` and `main` 13 | config.disable_monkey_patching! 14 | 15 | config.expect_with :rspec do |c| 16 | c.syntax = :expect 17 | end 18 | end 19 | 20 | require "rails/generators/testing/behavior" 21 | require "rails/generators/testing/setup_and_teardown" 22 | require "rails/generators/testing/assertions" 23 | 24 | RSpec.configure do |config| 25 | config.include Rails::Generators::Testing::Behavior, type: :generator 26 | config.include Rails::Generators::Testing::SetupAndTeardown, type: :generator 27 | config.include Rails::Generators::Testing::Assertions, type: :generator 28 | config.include Minitest::Assertions, type: :generator 29 | config.include FileUtils, type: :generator 30 | end 31 | -------------------------------------------------------------------------------- /spec/generators/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generators/supermail/install/install_generator" 5 | 6 | RSpec.describe Supermail::InstallGenerator, type: :generator do 7 | tests Supermail::InstallGenerator 8 | 9 | INSTALL_DESTINATION_PATH = Pathname.new(__dir__).join("../../tmp/generators") 10 | destination INSTALL_DESTINATION_PATH.to_s 11 | 12 | before { prepare_destination } 13 | after { FileUtils.rm_rf(INSTALL_DESTINATION_PATH) } 14 | 15 | describe "after running generator" do 16 | before { run_generator } 17 | 18 | describe "app/emails/application_email.rb" do 19 | subject { File.read(INSTALL_DESTINATION_PATH.join("app/emails/application_email.rb")) } 20 | it { is_expected.to match(/class ApplicationEmail < Supermail::Rails::Base/) } 21 | it { is_expected.to match(/def from = "website@example.com"/) } 22 | it { is_expected.to match(/The Example.com Team/) } 23 | end 24 | 25 | describe "app/emails directory" do 26 | subject { File.directory?(INSTALL_DESTINATION_PATH.join("app/emails")) } 27 | it { is_expected.to be true } 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /supermail.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/supermail/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "supermail" 7 | spec.version = Supermail::VERSION 8 | spec.authors = ["Brad Gessler"] 9 | spec.email = ["bradgessler@gmail.com"] 10 | 11 | spec.summary = "Build emails with plain ol' Ruby objects." 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/beautifulruby/supermail" 14 | spec.required_ruby_version = ">= 3.1.0" 15 | 16 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 17 | 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | spec.metadata["source_code_uri"] = spec.homepage 20 | spec.metadata["changelog_uri"] = spec.homepage 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | gemspec = File.basename(__FILE__) 25 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 26 | ls.readlines("\x0", chomp: true).reject do |f| 27 | (f == gemspec) || 28 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 29 | end 30 | end 31 | spec.bindir = "exe" 32 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 33 | spec.require_paths = ["lib"] 34 | 35 | # Uncomment to register a new dependency of your gem 36 | rails_version = ">= 7.0" 37 | spec.add_dependency "actionmailer", rails_version 38 | spec.add_development_dependency "rails", rails_version 39 | end 40 | -------------------------------------------------------------------------------- /spec/generators/email_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generators/supermail/email/email_generator" 5 | 6 | RSpec.describe Supermail::EmailGenerator, type: :generator do 7 | tests Supermail::EmailGenerator 8 | 9 | EMAIL_DESTINATION_PATH = Pathname.new(__dir__).join("../../tmp/generators") 10 | destination EMAIL_DESTINATION_PATH.to_s 11 | 12 | before { prepare_destination } 13 | after { FileUtils.rm_rf(EMAIL_DESTINATION_PATH) } 14 | 15 | describe "with namespaced email" do 16 | before { run_generator ["User::Welcome"] } 17 | 18 | describe "app/emails/user/welcome_email.rb" do 19 | subject { File.read(EMAIL_DESTINATION_PATH.join("app/emails/user/welcome_email.rb")) } 20 | it { is_expected.to match(/class User::WelcomeEmail < ApplicationEmail/) } 21 | it { is_expected.to match(/def body = <<~PLAIN/) } 22 | end 23 | end 24 | 25 | describe "with simple email" do 26 | before { run_generator ["Welcome"] } 27 | 28 | describe "app/emails/welcome_email.rb" do 29 | subject { File.read(EMAIL_DESTINATION_PATH.join("app/emails/welcome_email.rb")) } 30 | it { is_expected.to match(/class WelcomeEmail < ApplicationEmail/) } 31 | it { is_expected.to match(/def body = <<~PLAIN/) } 32 | end 33 | end 34 | 35 | describe "with email suffix provided" do 36 | before { run_generator ["WelcomeEmail"] } 37 | 38 | describe "app/emails/welcome_email.rb" do 39 | subject { File.read(EMAIL_DESTINATION_PATH.join("app/emails/welcome_email.rb")) } 40 | it { is_expected.to match(/class WelcomeEmail < ApplicationEmail/) } 41 | it { is_expected.not_to match(/WelcomeEmailEmail/) } 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/supermail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "supermail/version" 4 | require "action_mailer" 5 | require "erb" 6 | 7 | module Supermail 8 | class Error < StandardError; end 9 | 10 | module Rails 11 | # This is a bizzare work around for a commit that broke https://github.com/rails/rails/commit/c594ba4ffdb016c7b2a22055f41dfb2c4409594d 12 | # further proving the bewildering maze of indirection in Rails ActionMailer. 13 | class Mailer < ActionMailer::Base 14 | def mail(...) 15 | super(...) 16 | end 17 | 18 | def self.message_delivery(**) 19 | ActionMailer::MessageDelivery.new self, :mail, ** 20 | end 21 | end 22 | 23 | class Base 24 | delegate \ 25 | :deliver, 26 | :deliver_now, 27 | :deliver_later, 28 | :message, 29 | to: :message_delivery 30 | 31 | def to = nil 32 | def from = nil 33 | def subject = nil 34 | def cc = [] 35 | def bcc = [] 36 | def body = "" 37 | 38 | # Generate a mailto: URL with appropriate escaping. 39 | def mailto = MailTo.href(to:, from:, cc:, bcc:, subject:, body:) 40 | alias :mail_to :mailto 41 | 42 | private def message_delivery 43 | Rails::Mailer.message_delivery( 44 | to:, 45 | from:, 46 | cc:, 47 | bcc:, 48 | subject:, 49 | body: 50 | ) 51 | end 52 | end 53 | end 54 | 55 | module MailTo 56 | extend self 57 | 58 | def href(to:, **params) 59 | q = query(**params) 60 | q.empty? ? "mailto:#{to}" : "mailto:#{to}?#{q}" 61 | end 62 | 63 | def query(**params) 64 | params 65 | .compact # drop nils 66 | .reject { |k, v| v.is_a?(Array) && v.empty? } # drop empty arrays 67 | .map { |k, v| "#{k}=#{mailto_escape(v)}" } 68 | .join("&") 69 | end 70 | 71 | private 72 | 73 | def mailto_escape(str) 74 | ERB::Util.url_encode(str.to_s).tr("+", "%20") 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/supermail_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | class ExampleMailer < Supermail::Rails::Base 6 | def initialize(to:, from:, subject:, body:, cc: [], bcc: []) 7 | @to = to 8 | @from = from 9 | @subject = subject 10 | @body = body 11 | @cc = cc 12 | @bcc = bcc 13 | end 14 | 15 | attr_reader :to, :from, :subject, :body, :cc, :bcc 16 | end 17 | 18 | RSpec.describe ExampleMailer do 19 | let(:email) { described_class.new( 20 | to: "user@example.com", 21 | from: "support@example.com", 22 | subject: "Hello", 23 | body: "Hi there", 24 | cc: ["cc@example.com"], 25 | bcc: ["bcc@example.com"]) 26 | } 27 | let(:message) { email.message } 28 | subject { message } 29 | 30 | it "builds a Mail::Message" do 31 | expect(subject).to be_a(Mail::Message) 32 | end 33 | 34 | it "sets the to header" do 35 | expect(subject.to).to eq(["user@example.com"]) 36 | end 37 | 38 | it "sets the from header" do 39 | expect(subject.from).to eq(["support@example.com"]) 40 | end 41 | 42 | it "sets the subject header" do 43 | expect(subject.subject).to eq("Hello") 44 | end 45 | 46 | it "sets the body" do 47 | expect(subject.body.to_s).to eq("Hi there") 48 | end 49 | 50 | describe "#mailto" do 51 | it "passes all mail fields to the mailto URL" do 52 | result = email.mailto 53 | expect(result).to start_with("mailto:user@example.com?") 54 | expect(result).to include("from=support%40example.com") 55 | expect(result).to include("subject=Hello") 56 | expect(result).to include("body=Hi%20there") 57 | expect(result).to include("cc=%5B%22cc%40example.com%22%5D") 58 | expect(result).to include("bcc=%5B%22bcc%40example.com%22%5D") 59 | end 60 | end 61 | 62 | describe "delivery" do 63 | before do 64 | ActionMailer::Base.delivery_method = :test 65 | ActionMailer::Base.deliveries.clear 66 | ActiveJob::Base.queue_adapter = :test 67 | end 68 | 69 | describe "#deliver_now" do 70 | it "delivers the email through ActionMailer" do 71 | expect { 72 | email.deliver_now 73 | }.to change { ActionMailer::Base.deliveries.count }.by(1) 74 | end 75 | end 76 | 77 | describe "#deliver" do 78 | it "delivers the email through ActionMailer" do 79 | expect { 80 | email.deliver 81 | }.to change { ActionMailer::Base.deliveries.count }.by(1) 82 | end 83 | end 84 | 85 | describe "#deliver_later" do 86 | it "enqueues the email for delivery through ActiveJob" do 87 | expect { 88 | email.deliver_later 89 | }.to change { ActiveJob::Base.queue_adapter.enqueued_jobs.size }.by(1) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supermail 2 | 3 | Organize emails with plain 'ol Ruby objects in a Rails application, like this: 4 | 5 | ```ruby 6 | # ./app/email/user/welcome.rb 7 | class User::WelcomeEmail < ApplicationEmail 8 | def initialize(user:) 9 | @user = user 10 | end 11 | 12 | def to = @user.email 13 | def subject = "Welcome to Beautiful Ruby" 14 | def body 15 | super do 16 | <<~_ 17 | Hi #{@user.name}, 18 | 19 | You're going to learn a ton at https://beautifulruby.com. 20 | _ 21 | end 22 | end 23 | end 24 | ``` 25 | 26 | Contrast that with rails ActionMailer, where you will spend 20 minutes trying to figure out how to send an email. I created this gem because I got tired of digging through Rails docs to understand how to intialize an email and send it. PORO's FTW! 27 | 28 | ## Support this project 29 | 30 | Learn how to build UI's out of Ruby classes and support this project by ordering the [Phlex on Rails video course](https://beautifulruby.com/phlex). 31 | 32 | [![](https://immutable.terminalwire.com/hmM9jvv7yF89frBUfjikUfRmdUsTVZ8YvXc7OnnYoERXfLJLzDcj5dFM7qdfMG2bqQLuw633Zt1gl3O7z0zKmH6k8QmifN7z0kJo.png)](https://beautifulruby.com/phlex/forms/introduction) 33 | 34 | 35 | ## Installation 36 | 37 | Install the gem and add to the application's Gemfile by executing: 38 | 39 | ```bash 40 | bundle add supermail 41 | ``` 42 | 43 | Then install it in Rails. 44 | 45 | ```bash 46 | rails generate supermail:install 47 | ``` 48 | 49 | This creates the `ApplicationEmail` class at `app/emails/application_email.rb` where you can customize the base for all emails, including setting defaults like the `from` address. 50 | 51 | ```ruby 52 | class ApplicationEmail < Supermail::Rails::Base 53 | def from = "website@example.com" 54 | def to = nil 55 | def subject = nil 56 | def body 57 | <<~_ 58 | #{yield if block_given?} 59 | 60 | Best, 61 | 62 | The Example.com Team 63 | _ 64 | end 65 | end 66 | ``` 67 | 68 | ## Usage 69 | 70 | To generate a new email, run the following command: 71 | 72 | ```bash 73 | rails generate supermail:email User::Welcome 74 | ``` 75 | 76 | This will create a new email class in `app/mailers/user/welcome_email.rb`. 77 | 78 | ```ruby 79 | # ./app/email/user/welcome.rb 80 | class User::WelcomeEmail < ApplicationEmail 81 | def body = <<~PLAIN 82 | Hello there! 83 | PLAIN 84 | end 85 | ``` 86 | You can customize the email by overriding the `to`, `from`, `subject`, and `body` methods.s 87 | 88 | ```ruby 89 | # ./app/email/user/welcome.rb 90 | class User::WelcomeEmail < ApplicationEmail 91 | def initialize(user:) 92 | @user = user 93 | end 94 | 95 | def to = @user.email 96 | def subject = "Welcome to the website" 97 | def body 98 | super do 99 | <<~_ 100 | Hi #{@user.name}, 101 | 102 | Welcome to the website We're excited to have you on board. 103 | _ 104 | end 105 | end 106 | end 107 | ``` 108 | 109 | ### Send emails from the server 110 | 111 | Then, to send the email. 112 | 113 | ```ruby 114 | User::Welcome.new(user: User.first).deliver_now 115 | ``` 116 | 117 | If you want to tweak the message on the fly, you can modify the message, then deliver it. 118 | 119 | ```ruby 120 | User::Welcome.new(user: User.first).message.tap do 121 | it.to << "another@example.com" 122 | end.deliver_now 123 | ``` 124 | 125 | ### Launch the user's email client 126 | 127 | Supermail clases can be used to generate `mailto:` links with Rails helpers. 128 | 129 | ```erb 130 | <%= link_to Support::OrderEmail.new( 131 | user: current_user, 132 | order: @order 133 | ).mail_to s%> 134 | ``` 135 | 136 | This opens your users email client with prefilled information. A support email about an order might look like this: 137 | 138 | ```ruby 139 | class Support::OrderEmail < ApplicationEmail 140 | def initialize(user:, order:) 141 | @user = user 142 | @order = order 143 | end 144 | 145 | def to = "support@example.com" 146 | def from = @user.email 147 | def subject = "Question about order #{@order.id}" 148 | def body = <<~BODY 149 | Hi Support, 150 | 151 | I need help with my order #{@order.id}. 152 | 153 | Thanks, 154 | 155 | #{@user.name} 156 | BODY 157 | end 158 | ``` 159 | 160 | ## Development 161 | 162 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 163 | 164 | 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). 165 | 166 | ## Contributing 167 | 168 | Bug reports and pull requests are welcome on GitHub at https://github.com/rubymonolith/supermail. 169 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | supermail (0.2.2) 5 | actionmailer (>= 7.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.2.2.2) 11 | actionpack (= 7.2.2.2) 12 | activesupport (= 7.2.2.2) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (7.2.2.2) 17 | actionpack (= 7.2.2.2) 18 | activejob (= 7.2.2.2) 19 | activerecord (= 7.2.2.2) 20 | activestorage (= 7.2.2.2) 21 | activesupport (= 7.2.2.2) 22 | mail (>= 2.8.0) 23 | actionmailer (7.2.2.2) 24 | actionpack (= 7.2.2.2) 25 | actionview (= 7.2.2.2) 26 | activejob (= 7.2.2.2) 27 | activesupport (= 7.2.2.2) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (7.2.2.2) 31 | actionview (= 7.2.2.2) 32 | activesupport (= 7.2.2.2) 33 | nokogiri (>= 1.8.5) 34 | racc 35 | rack (>= 2.2.4, < 3.2) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | useragent (~> 0.16) 41 | actiontext (7.2.2.2) 42 | actionpack (= 7.2.2.2) 43 | activerecord (= 7.2.2.2) 44 | activestorage (= 7.2.2.2) 45 | activesupport (= 7.2.2.2) 46 | globalid (>= 0.6.0) 47 | nokogiri (>= 1.8.5) 48 | actionview (7.2.2.2) 49 | activesupport (= 7.2.2.2) 50 | builder (~> 3.1) 51 | erubi (~> 1.11) 52 | rails-dom-testing (~> 2.2) 53 | rails-html-sanitizer (~> 1.6) 54 | activejob (7.2.2.2) 55 | activesupport (= 7.2.2.2) 56 | globalid (>= 0.3.6) 57 | activemodel (7.2.2.2) 58 | activesupport (= 7.2.2.2) 59 | activerecord (7.2.2.2) 60 | activemodel (= 7.2.2.2) 61 | activesupport (= 7.2.2.2) 62 | timeout (>= 0.4.0) 63 | activestorage (7.2.2.2) 64 | actionpack (= 7.2.2.2) 65 | activejob (= 7.2.2.2) 66 | activerecord (= 7.2.2.2) 67 | activesupport (= 7.2.2.2) 68 | marcel (~> 1.0) 69 | activesupport (7.2.2.2) 70 | base64 71 | benchmark (>= 0.3) 72 | bigdecimal 73 | concurrent-ruby (~> 1.0, >= 1.3.1) 74 | connection_pool (>= 2.2.5) 75 | drb 76 | i18n (>= 1.6, < 2) 77 | logger (>= 1.4.2) 78 | minitest (>= 5.1) 79 | securerandom (>= 0.3) 80 | tzinfo (~> 2.0, >= 2.0.5) 81 | base64 (0.3.0) 82 | benchmark (0.4.1) 83 | bigdecimal (3.2.3) 84 | builder (3.3.0) 85 | concurrent-ruby (1.3.5) 86 | connection_pool (2.5.4) 87 | crass (1.0.6) 88 | date (3.4.1) 89 | diff-lcs (1.6.2) 90 | drb (2.2.3) 91 | erb (5.0.2) 92 | erubi (1.13.1) 93 | globalid (1.3.0) 94 | activesupport (>= 6.1) 95 | i18n (1.14.7) 96 | concurrent-ruby (~> 1.0) 97 | io-console (0.8.1) 98 | irb (1.15.2) 99 | pp (>= 0.6.0) 100 | rdoc (>= 4.0.0) 101 | reline (>= 0.4.2) 102 | logger (1.7.0) 103 | loofah (2.24.1) 104 | crass (~> 1.0.2) 105 | nokogiri (>= 1.12.0) 106 | mail (2.8.1) 107 | mini_mime (>= 0.1.1) 108 | net-imap 109 | net-pop 110 | net-smtp 111 | marcel (1.1.0) 112 | mini_mime (1.1.5) 113 | mini_portile2 (2.8.9) 114 | minitest (5.25.5) 115 | net-imap (0.5.10) 116 | date 117 | net-protocol 118 | net-pop (0.1.2) 119 | net-protocol 120 | net-protocol (0.2.2) 121 | timeout 122 | net-smtp (0.5.1) 123 | net-protocol 124 | nio4r (2.7.4) 125 | nokogiri (1.18.10) 126 | mini_portile2 (~> 2.8.2) 127 | racc (~> 1.4) 128 | nokogiri (1.18.10-arm64-darwin) 129 | racc (~> 1.4) 130 | pp (0.6.2) 131 | prettyprint 132 | prettyprint (0.2.0) 133 | psych (5.2.6) 134 | date 135 | stringio 136 | racc (1.8.1) 137 | rack (3.1.16) 138 | rack-session (2.1.1) 139 | base64 (>= 0.1.0) 140 | rack (>= 3.0.0) 141 | rack-test (2.2.0) 142 | rack (>= 1.3) 143 | rackup (2.2.1) 144 | rack (>= 3) 145 | rails (7.2.2.2) 146 | actioncable (= 7.2.2.2) 147 | actionmailbox (= 7.2.2.2) 148 | actionmailer (= 7.2.2.2) 149 | actionpack (= 7.2.2.2) 150 | actiontext (= 7.2.2.2) 151 | actionview (= 7.2.2.2) 152 | activejob (= 7.2.2.2) 153 | activemodel (= 7.2.2.2) 154 | activerecord (= 7.2.2.2) 155 | activestorage (= 7.2.2.2) 156 | activesupport (= 7.2.2.2) 157 | bundler (>= 1.15.0) 158 | railties (= 7.2.2.2) 159 | rails-dom-testing (2.3.0) 160 | activesupport (>= 5.0.0) 161 | minitest 162 | nokogiri (>= 1.6) 163 | rails-html-sanitizer (1.6.2) 164 | loofah (~> 2.21) 165 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 166 | railties (7.2.2.2) 167 | actionpack (= 7.2.2.2) 168 | activesupport (= 7.2.2.2) 169 | irb (~> 1.13) 170 | rackup (>= 1.0.0) 171 | rake (>= 12.2) 172 | thor (~> 1.0, >= 1.2.2) 173 | zeitwerk (~> 2.6) 174 | rake (13.3.0) 175 | rdoc (6.14.2) 176 | erb 177 | psych (>= 4.0.0) 178 | reline (0.6.2) 179 | io-console (~> 0.5) 180 | rspec (3.13.1) 181 | rspec-core (~> 3.13.0) 182 | rspec-expectations (~> 3.13.0) 183 | rspec-mocks (~> 3.13.0) 184 | rspec-core (3.13.5) 185 | rspec-support (~> 3.13.0) 186 | rspec-expectations (3.13.5) 187 | diff-lcs (>= 1.2.0, < 2.0) 188 | rspec-support (~> 3.13.0) 189 | rspec-mocks (3.13.5) 190 | diff-lcs (>= 1.2.0, < 2.0) 191 | rspec-support (~> 3.13.0) 192 | rspec-support (3.13.6) 193 | securerandom (0.4.1) 194 | stringio (3.1.7) 195 | thor (1.4.0) 196 | timeout (0.4.3) 197 | tzinfo (2.0.6) 198 | concurrent-ruby (~> 1.0) 199 | useragent (0.16.11) 200 | websocket-driver (0.8.0) 201 | base64 202 | websocket-extensions (>= 0.1.0) 203 | websocket-extensions (0.1.5) 204 | zeitwerk (2.7.3) 205 | 206 | PLATFORMS 207 | arm64-darwin-24 208 | ruby 209 | 210 | DEPENDENCIES 211 | irb 212 | rails (>= 7.0) 213 | rake (~> 13.0) 214 | rspec (~> 3.0) 215 | supermail! 216 | 217 | BUNDLED WITH 218 | 2.6.5 219 | -------------------------------------------------------------------------------- /spec/mailto_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Supermail::MailTo do 6 | describe ".href" do 7 | context "with minimal parameters" do 8 | subject { described_class.href(to: "test@example.com") } 9 | 10 | it { is_expected.to eq("mailto:test@example.com") } 11 | end 12 | 13 | context "with all parameters" do 14 | subject do 15 | described_class.href( 16 | to: "recipient@example.com", 17 | from: "sender@example.com", 18 | cc: "cc@example.com", 19 | bcc: "bcc@example.com", 20 | subject: "Test Subject", 21 | body: "Test body content" 22 | ) 23 | end 24 | 25 | it { is_expected.to start_with("mailto:recipient@example.com?") } 26 | it { is_expected.to include("from=sender%40example.com") } 27 | it { is_expected.to include("cc=cc%40example.com") } 28 | it { is_expected.to include("bcc=bcc%40example.com") } 29 | it { is_expected.to include("subject=Test%20Subject") } 30 | it { is_expected.to include("body=Test%20body%20content") } 31 | end 32 | 33 | context "with nil parameters" do 34 | subject do 35 | described_class.href( 36 | to: "test@example.com", 37 | from: nil, 38 | subject: "Hello", 39 | cc: nil 40 | ) 41 | end 42 | 43 | it { is_expected.to eq("mailto:test@example.com?subject=Hello") } 44 | end 45 | 46 | context "with empty arrays" do 47 | subject { described_class.href(to: "test@example.com", cc: []) } 48 | 49 | it { is_expected.to eq("mailto:test@example.com") } 50 | end 51 | 52 | context "with special characters" do 53 | subject do 54 | described_class.href( 55 | to: "test+tag@example.com", 56 | subject: "Hello & Welcome!", 57 | body: "Line 1\nLine 2" 58 | ) 59 | end 60 | 61 | it { is_expected.to start_with("mailto:test+tag@example.com?") } 62 | it { is_expected.to include("subject=Hello%20%26%20Welcome%21") } 63 | it { is_expected.to include("body=Line%201%0ALine%202") } 64 | end 65 | 66 | context "with unicode characters" do 67 | subject { described_class.href(to: "test@example.com", subject: "Héllo Wørld! 🎉") } 68 | 69 | it { is_expected.to start_with("mailto:test@example.com?subject=") } 70 | it { is_expected.to include("H%C3%A9llo") } 71 | it { is_expected.to include("W%C3%B8rld") } 72 | end 73 | 74 | context "with multiple CC recipients" do 75 | subject { described_class.href(to: "test@example.com", cc: ["cc1@example.com", "cc2@example.com"]) } 76 | 77 | it { is_expected.to eq("mailto:test@example.com?cc=%5B%22cc1%40example.com%22%2C%20%22cc2%40example.com%22%5D") } 78 | end 79 | 80 | context "with multiple BCC recipients" do 81 | subject { described_class.href(to: "test@example.com", bcc: ["bcc1@example.com", "bcc2@example.com"]) } 82 | 83 | it { is_expected.to eq("mailto:test@example.com?bcc=%5B%22bcc1%40example.com%22%2C%20%22bcc2%40example.com%22%5D") } 84 | end 85 | end 86 | 87 | describe ".query" do 88 | context "with simple parameters" do 89 | subject { described_class.query(subject: "Hello", body: "World") } 90 | 91 | it { is_expected.to eq("subject=Hello&body=World") } 92 | end 93 | 94 | context "with no parameters" do 95 | subject { described_class.query } 96 | 97 | it { is_expected.to eq("") } 98 | end 99 | 100 | context "with nil values" do 101 | subject { described_class.query(subject: "Hello", from: nil, body: "World") } 102 | 103 | it { is_expected.to eq("subject=Hello&body=World") } 104 | end 105 | 106 | context "with special characters" do 107 | subject { described_class.query(subject: "Hello & Goodbye", body: "Line 1\nLine 2") } 108 | 109 | it { is_expected.to eq("subject=Hello%20%26%20Goodbye&body=Line%201%0ALine%202") } 110 | end 111 | 112 | context "with mixed parameter types" do 113 | subject do 114 | described_class.query( 115 | subject: "Test", 116 | cc: ["test1@example.com", "test2@example.com"], 117 | from: nil, 118 | body: "" 119 | ) 120 | end 121 | 122 | it { is_expected.to include("subject=Test") } 123 | it { is_expected.to include("cc=%5B%22test1%40example.com%22%2C%20%22test2%40example.com%22%5D") } 124 | it { is_expected.to include("body=") } 125 | it { is_expected.not_to include("from=") } 126 | end 127 | 128 | context "with empty arrays" do 129 | subject { described_class.query(subject: "Test", cc: [], bcc: [], from: "test@example.com") } 130 | 131 | it { is_expected.to eq("subject=Test&from=test%40example.com") } 132 | end 133 | 134 | context "with mixed empty arrays and nil values" do 135 | subject { described_class.query(subject: "Test", cc: [], bcc: nil, from: "test@example.com", body: "") } 136 | 137 | it { is_expected.to eq("subject=Test&from=test%40example.com&body=") } 138 | end 139 | end 140 | 141 | describe ".mailto_escape" do 142 | it "is private" do 143 | expect(described_class.private_methods).to include(:mailto_escape) 144 | end 145 | 146 | context "with simple string" do 147 | subject { described_class.send(:mailto_escape, "Hello World") } 148 | 149 | it { is_expected.to eq("Hello%20World") } 150 | end 151 | 152 | context "with plus signs" do 153 | subject { described_class.send(:mailto_escape, "test+tag@example.com") } 154 | 155 | it { is_expected.to eq("test%2Btag%40example.com") } 156 | end 157 | 158 | context "with newlines" do 159 | subject { described_class.send(:mailto_escape, "Line 1\nLine 2") } 160 | 161 | it { is_expected.to eq("Line%201%0ALine%202") } 162 | end 163 | 164 | context "with ampersands" do 165 | subject { described_class.send(:mailto_escape, "Hello & Goodbye") } 166 | 167 | it { is_expected.to eq("Hello%20%26%20Goodbye") } 168 | end 169 | 170 | context "with non-string objects" do 171 | subject { described_class.send(:mailto_escape, 123) } 172 | 173 | it { is_expected.to eq("123") } 174 | end 175 | 176 | context "with carriage returns and line feeds" do 177 | subject { described_class.send(:mailto_escape, "Line 1\r\nLine 2") } 178 | 179 | it { is_expected.to eq("Line%201%0D%0ALine%202") } 180 | end 181 | 182 | context "with unicode characters" do 183 | subject { described_class.send(:mailto_escape, "Héllo 🎉") } 184 | 185 | it { is_expected.to include("H%C3%A9llo") } 186 | end 187 | 188 | context "with percent signs" do 189 | subject { described_class.send(:mailto_escape, "50% off") } 190 | 191 | it { is_expected.to eq("50%25%20off") } 192 | end 193 | 194 | context "with empty strings" do 195 | subject { described_class.send(:mailto_escape, "") } 196 | 197 | it { is_expected.to eq("") } 198 | end 199 | end 200 | end 201 | --------------------------------------------------------------------------------