├── .github ├── FUNDING.yml ├── cover.jpg ├── logo-dark.svg ├── logo-light.svg └── workflows │ └── main.yml ├── .gitignore ├── .standard.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── controllers │ └── courrier │ │ ├── previews │ │ └── cleanups_controller.rb │ │ └── previews_controller.rb └── views │ └── courrier │ └── previews │ └── index.html.erb ├── bin ├── console ├── release └── setup ├── config └── routes.rb ├── courrier.gemspec ├── lib ├── courrier.rb ├── courrier │ ├── configuration.rb │ ├── configuration │ │ ├── inbox.rb │ │ └── providers.rb │ ├── email.rb │ ├── email │ │ ├── address.rb │ │ ├── layouts.rb │ │ ├── options.rb │ │ ├── provider.rb │ │ ├── providers │ │ │ ├── base.rb │ │ │ ├── inbox.rb │ │ │ ├── inbox │ │ │ │ └── default.html.erb │ │ │ ├── logger.rb │ │ │ ├── loops.rb │ │ │ ├── mailgun.rb │ │ │ ├── mailjet.rb │ │ │ ├── mailpace.rb │ │ │ ├── postmark.rb │ │ │ ├── resend.rb │ │ │ ├── sendgrid.rb │ │ │ ├── sparkpost.rb │ │ │ └── userlist.rb │ │ ├── request.rb │ │ ├── result.rb │ │ └── transformer.rb │ ├── engine.rb │ ├── errors.rb │ ├── jobs │ │ └── email_delivery_job.rb │ ├── railtie.rb │ ├── tasks │ │ └── courrier.rake │ └── version.rb └── generators │ └── courrier │ ├── email_generator.rb │ ├── install_generator.rb │ └── templates │ ├── email.rb.tt │ ├── email │ ├── password_reset.rb.tt │ └── welcome.rb.tt │ └── initializer.rb.tt └── test ├── courrier ├── email │ ├── address_test.rb │ ├── layouts_test.rb │ ├── provider_test.rb │ ├── providers │ │ ├── inbox_test.rb │ │ └── logger_test.rb │ ├── result_test.rb │ └── transformer_test.rb ├── email_configuration_test.rb ├── email_delivery_test.rb ├── email_test.rb └── providers │ └── base_test.rb ├── fixtures ├── email_with_string_layouts.rb ├── test_email.rb ├── test_email_with_context.rb ├── test_email_with_mixed_layouts.rb ├── test_email_with_url.rb └── test_provider.rb ├── support └── test_email_helpers.rb └── test_helper.rb /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Rails-Designer] 2 | -------------------------------------------------------------------------------- /.github/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rails-Designer/courrier/d98c39568f200e96e0a13d01d7974c90d025b898/.github/cover.jpg -------------------------------------------------------------------------------- /.github/logo-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/logo-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.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-22.04 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - "3.2.0" 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 Standard 27 | run: bundle exec standardrb 28 | - name: Run the default task 29 | run: bundle exec rake 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.1.0 2 | ignore: 3 | - "tmp/**/*" 4 | - "test/**/*" 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :development do 8 | gem "standard", "~> 1.49" 9 | end 10 | 11 | group :development, :test do 12 | gem "rake", "~> 13.2", ">= 13.2.1" 13 | gem "minitest", "~> 5.25", ">= 5.25.5" 14 | gem "debug", "~> 1.9", ">= 1.9.2" 15 | end 16 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | courrier (0.7.0) 5 | launchy (>= 3.1, < 4) 6 | nokogiri (>= 1.18, < 2) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | addressable (2.8.7) 12 | public_suffix (>= 2.0.2, < 7.0) 13 | ast (2.4.3) 14 | childprocess (5.1.0) 15 | logger (~> 1.5) 16 | date (3.4.1) 17 | debug (1.10.0) 18 | irb (~> 1.10) 19 | reline (>= 0.3.8) 20 | io-console (0.8.0) 21 | irb (1.15.2) 22 | pp (>= 0.6.0) 23 | rdoc (>= 4.0.0) 24 | reline (>= 0.4.2) 25 | json (2.11.1) 26 | language_server-protocol (3.17.0.4) 27 | launchy (3.1.1) 28 | addressable (~> 2.8) 29 | childprocess (~> 5.0) 30 | logger (~> 1.6) 31 | lint_roller (1.1.0) 32 | logger (1.7.0) 33 | minitest (5.25.5) 34 | nokogiri (1.18.8-arm64-darwin) 35 | racc (~> 1.4) 36 | nokogiri (1.18.8-x86_64-darwin) 37 | racc (~> 1.4) 38 | nokogiri (1.18.8-x86_64-linux-gnu) 39 | racc (~> 1.4) 40 | parallel (1.27.0) 41 | parser (3.3.8.0) 42 | ast (~> 2.4.1) 43 | racc 44 | pp (0.6.2) 45 | prettyprint 46 | prettyprint (0.2.0) 47 | prism (1.4.0) 48 | psych (5.2.3) 49 | date 50 | stringio 51 | public_suffix (6.0.2) 52 | racc (1.8.1) 53 | rainbow (3.1.1) 54 | rake (13.2.1) 55 | rdoc (6.13.1) 56 | psych (>= 4.0.0) 57 | regexp_parser (2.10.0) 58 | reline (0.6.1) 59 | io-console (~> 0.5) 60 | rubocop (1.75.3) 61 | json (~> 2.3) 62 | language_server-protocol (~> 3.17.0.2) 63 | lint_roller (~> 1.1.0) 64 | parallel (~> 1.10) 65 | parser (>= 3.3.0.2) 66 | rainbow (>= 2.2.2, < 4.0) 67 | regexp_parser (>= 2.9.3, < 3.0) 68 | rubocop-ast (>= 1.44.0, < 2.0) 69 | ruby-progressbar (~> 1.7) 70 | unicode-display_width (>= 2.4.0, < 4.0) 71 | rubocop-ast (1.44.1) 72 | parser (>= 3.3.7.2) 73 | prism (~> 1.4) 74 | rubocop-performance (1.25.0) 75 | lint_roller (~> 1.1) 76 | rubocop (>= 1.75.0, < 2.0) 77 | rubocop-ast (>= 1.38.0, < 2.0) 78 | ruby-progressbar (1.13.0) 79 | standard (1.49.0) 80 | language_server-protocol (~> 3.17.0.2) 81 | lint_roller (~> 1.0) 82 | rubocop (~> 1.75.2) 83 | standard-custom (~> 1.0.0) 84 | standard-performance (~> 1.8) 85 | standard-custom (1.0.2) 86 | lint_roller (~> 1.0) 87 | rubocop (~> 1.50) 88 | standard-performance (1.8.0) 89 | lint_roller (~> 1.1) 90 | rubocop-performance (~> 1.25.0) 91 | stringio (3.1.7) 92 | unicode-display_width (3.1.4) 93 | unicode-emoji (~> 4.0, >= 4.0.4) 94 | unicode-emoji (4.0.4) 95 | 96 | PLATFORMS 97 | arm64-darwin-23 98 | x86_64-darwin-23 99 | x86_64-linux 100 | 101 | DEPENDENCIES 102 | courrier! 103 | debug (~> 1.9, >= 1.9.2) 104 | minitest (~> 5.25, >= 5.25.5) 105 | rake (~> 13.2, >= 13.2.1) 106 | standard (~> 1.49) 107 | 108 | BUNDLED WITH 109 | 2.6.8 110 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Rails Designer 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Courrier 2 | 3 | API-powered email delivery for Ruby apps. 4 | 5 | ![A cute cartoon mascot wearing a blue postal uniform with red scarf and cap, carrying a leather messenger bag, representing an API-powered email delivery gem for Ruby apps](https://raw.githubusercontent.com/Rails-Designer/courrier/HEAD/.github/cover.jpg) 6 | 7 | ```ruby 8 | # Quick example 9 | class OrderEmail < Courrier::Email 10 | def subject = "Here is your order!" 11 | 12 | def text = "Thanks for ordering" 13 | 14 | def html = "

Thanks for ordering

" 15 | end 16 | 17 | OrderEmail.deliver to: "recipient@railsdesigner.com" 18 | ``` 19 | 20 | 21 | 22 | 23 | 24 | Rails Designer logo 25 | 26 | 27 | 28 | **Sponsored By [Rails Designer](https://railsdesigner.com/)** 29 | 30 | 31 | ## Installation 32 | 33 | Add the gem: 34 | ```bash 35 | bundle add courrier 36 | ``` 37 | 38 | Generate the configuration file: 39 | ```bash 40 | bin/rails generate courrier:install 41 | ``` 42 | 43 | This creates `config/initializers/courrier.rb` for configuring email providers and default settings. 44 | 45 | 46 | ## Usage 47 | 48 | Generate a new email: 49 | ```bash 50 | bin/rails generate courrier:email Order 51 | ``` 52 | 53 | ```ruby 54 | class OrderEmail < Courrier::Email 55 | def subject = "Here is your order!" 56 | 57 | def text 58 | <<~TEXT 59 | text body here 60 | TEXT 61 | end 62 | 63 | def html 64 | <<~HTML 65 | html body here 66 | HTML 67 | end 68 | end 69 | 70 | # OrderEmail.deliver to: "recipient@railsdesigner.com" 71 | ``` 72 | 73 | 💡 Write your email content using the [Minimal Email Editor](https://railsdesigner.com/minimal-email-editor/). 74 | 75 | 76 | ## Configuration 77 | 78 | Courrier uses a configuration system with three levels (from lowest to highest priority): 79 | 80 | 1. **Global configuration** 81 | ```ruby 82 | Courrier.configure do |config| 83 | config.provider = "postmark" 84 | config.api_key = "xyz" 85 | config.from = "devs@railsdesigner.com" 86 | config.default_url_options = { host: "railsdesigner.com" } 87 | 88 | # Provider-specific configuration 89 | config.providers.loops.transactional_id = "default-template" 90 | config.providers.mailgun.domain = "notifications.railsdesigner.com" 91 | end 92 | ``` 93 | 94 | 2. **Email class defaults** 95 | ```ruby 96 | class OrderEmail < Courrier::Email 97 | configure from: "orders@railsdesigner.com", 98 | cc: "records@railsdesigner.com", 99 | provider: "mailgun" 100 | end 101 | ``` 102 | 103 | 3. **Instance options** 104 | ```ruby 105 | OrderEmail.deliver to: "recipient@railsdesigner.com",\ 106 | from: "shop@railsdesigner.com",\ 107 | provider: "sendgrid",\ 108 | api_key: "sk_a1b1c3" 109 | ``` 110 | 111 | 112 | Provider and API key settings can be overridden using environment variables (`COURRIER_PROVIDER` and `COURRIER_API_KEY`) for both global configuration and email class defaults. 113 | 114 | 115 | ## Custom Attributes 116 | 117 | Besides the standard email attributes (`from`, `to`, `reply_to`, etc.), you can pass any additional attributes that will be available in your email templates: 118 | ```ruby 119 | OrderEmail.deliver to: "recipient@railsdesigner.com",\ 120 | download_url: downloads_path(token: "token") 121 | ``` 122 | 123 | These custom attributes are accessible directly in your email class: 124 | ```ruby 125 | def text 126 | <<~TEXT 127 | #{download_url} 128 | TEXT 129 | end 130 | ``` 131 | 132 | 133 | ## Result Object 134 | 135 | When sending an email through Courrier, a `Result` object is returned that provides information about the delivery attempt. This object offers a simple interface to check the status and access response data. 136 | 137 | 138 | ### Available Methods 139 | 140 | | Method | Return Type | Description | 141 | |:-------|:-----------|:------------| 142 | | `success?` | Boolean | Returns `true` if the API request was successful | 143 | | `response` | Net::HTTP::Response | The raw HTTP response from the email provider | 144 | | `data` | Hash | Parsed JSON response body from the provider | 145 | | `error` | Exception | Contains any error that occurred during delivery | 146 | 147 | 148 | ### Example 149 | 150 | ```ruby 151 | delivery = OrderEmail.deliver(to: "recipient@example.com") 152 | 153 | if delivery.success? 154 | puts "Email sent successfully!" 155 | puts "Provider response: #{delivery.data}" 156 | else 157 | puts "Failed to send email: #{delivery.error}" 158 | end 159 | ``` 160 | 161 | 162 | ## Providers 163 | 164 | Courrier supports these transactional email providers: 165 | 166 | - [Loops](https://loops.so) 167 | - [Mailgun](https://mailgun.com) 168 | - [MailPace](https://mailpace.com) 169 | - [Postmark](https://postmarkapp.com) 170 | - [Resend](https://resend.com) 171 | - [Userlist](https://userlist.com) 172 | 173 | 174 | ## More Features 175 | 176 | Additional functionality to help with development and testing: 177 | 178 | 179 | ### Background Jobs (Rails only) 180 | 181 | Use `deliver_later` to enqueue delivering using Rails' ActiveJob. You can set 182 | various ActiveJob-supported options in the email class, like so: `enqueue queue: "emails", wait: 5.minutes`. 183 | 184 | - `queue`, enqueue the email on the specified queue; 185 | - `wait`, enqueue the email to be delivered with a delay; 186 | - `wait_until`, enqueue the email to be delivered at (after) a specific date/time; 187 | - `priority`, enqueues the email with the specified priority. 188 | 189 | 190 | ### Inbox (Rails only) 191 | 192 | You can preview your emails in the inbox: 193 | ```ruby 194 | config.provider = "inbox" 195 | 196 | # And add to your routes: 197 | mount Courrier::Engine => "/courrier" 198 | ``` 199 | 200 | If you want to automatically open every email in your default browser: 201 | ```ruby 202 | config.provider = "inbox" 203 | config.inbox.auto_open = true 204 | ``` 205 | 206 | Emails are automatically cleared with `bin/rails tmp:clear`, or manually with `bin/rails courrier:clear`. 207 | 208 | 209 | ### Layout Support 210 | 211 | Wrap your email content using layouts: 212 | ```ruby 213 | class OrderEmail < Courrier::Email 214 | layout text: "%{content}\n\nThanks for your order!", 215 | html: "
\n%{content}\n
" 216 | end 217 | ``` 218 | 219 | Using a method: 220 | ```ruby 221 | class OrderEmail < Courrier::Email 222 | layout html: :html_layout 223 | 224 | def html_layout 225 | <<~HTML 226 |
227 | %{content} 228 |
229 | HTML 230 | end 231 | end 232 | ``` 233 | 234 | Using a separate class: 235 | ```ruby 236 | class OrderEmail < Courrier::Email 237 | layout html: OrderLayout 238 | end 239 | 240 | class OrderLayout 241 | self.call 242 | <<~HTML 243 |
244 | %{content} 245 |
246 | HTML 247 | end 248 | end 249 | ``` 250 | 251 | 252 | ### Auto-generate Text from HTML 253 | 254 | Automatically generate plain text versions from your HTML emails: 255 | ```ruby 256 | config.auto_generate_text = true # Defaults to false 257 | ``` 258 | 259 | 260 | ### Email Address Helper 261 | 262 | Compose email addresses with display names: 263 | ```ruby 264 | class SignupsController < ApplicationController 265 | def create 266 | recipient = email_with_name("devs@railsdesigner.com", "Rails Designer Devs") 267 | 268 | WelcomeEmail.deliver to: recipient 269 | end 270 | end 271 | ``` 272 | 273 | In Plain Ruby Objects: 274 | ```ruby 275 | class Signup 276 | include Courrier::Email::Address 277 | 278 | def send_welcome_email(user) 279 | recipient = email_with_name(user.email_address, user.name) 280 | 281 | WelcomeEmail.deliver to: recipient 282 | end 283 | end 284 | ``` 285 | 286 | 287 | ### Logger Provider 288 | 289 | Use Ruby's built-in Logger for development and testing: 290 | 291 | ```ruby 292 | config.provider = "logger" # Outputs emails to STDOUT 293 | config.logger = custom_logger # Optional: defaults to ::Logger.new($stdout) 294 | ``` 295 | 296 | ### Custom Providers 297 | 298 | Create your own provider by inheriting from `Courrier::Email::Providers::Base`: 299 | ```ruby 300 | class CustomProvider < Courrier::Email::Providers::Base 301 | ENDPOINT_URL = "" 302 | 303 | def body = "" 304 | 305 | def headers = "" 306 | end 307 | ``` 308 | 309 | Then configure it: 310 | ```ruby 311 | config.provider = "CustomProvider" 312 | ``` 313 | 314 | Check the [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/email/providers) for implementation examples. 315 | 316 | 317 | ## FAQ 318 | 319 | ### Is this a replacement for ActionMailer? 320 | Yes! While different in approach, Courrier can fully replace ActionMailer. It's a modern alternative that focuses on API-based delivery. The main difference is in how emails are structured - Courrier uses a more straightforward, class-based approach. 321 | 322 | ### Is this for Rails only? 323 | Not at all! While Courrier has some Rails-specific goodies (like the inbox preview feature and generators), it works great with any Ruby application. 324 | 325 | ### Can it send using SMTP? 326 | No - Courrier is specifically built for API-based email delivery. If SMTP is needed, ActionMailer would be a better choices. 327 | 328 | ### Can separate view templates be created (like ActionMailer)? 329 | The approach is different here. Instead of separate view files, email content is defined right in the email class using `text` and `html` methods. Layouts can be used to share common templates. This makes emails more self-contained and easier to reason about. 330 | 331 | ### What's the main benefit over ActionMailer? 332 | Courrier offers a simpler, more modern approach to sending emails. Each email is a standalone class, configuration is straightforward (typically just only an API key is needed) and it packs few quality-of-life features (like the inbox feature and auto-generate text version). 333 | 334 | 335 | ## Contributing 336 | 337 | This project uses [Standard](https://github.com/testdouble/standard) for formatting Ruby code. Please make sure to run `rake` before submitting pull requests. 338 | 339 | 340 | ## License 341 | 342 | Courrier is released under the [MIT License](https://opensource.org/licenses/MIT). 343 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "minitest/test_task" 5 | require "standard/rake" 6 | 7 | Minitest::TestTask.create 8 | 9 | task default: %i[test standard] 10 | -------------------------------------------------------------------------------- /app/controllers/courrier/previews/cleanups_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | module Previews 5 | class CleanupsController < ActionController::Base 6 | def create 7 | system("bin/rails courrier:clear") 8 | 9 | redirect_to root_path 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/courrier/previews_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class PreviewsController < ActionController::Base 5 | def index 6 | @emails = emails.map { Courrier::Email::Providers::Inbox::Email.from_file(_1) } 7 | end 8 | 9 | def show 10 | file_path = File.join(Courrier.configuration.inbox.destination, params[:id]) 11 | content = File.read(file_path) 12 | 13 | render html: content.html_safe, layout: false 14 | end 15 | 16 | private 17 | 18 | def emails 19 | @emails ||= Dir.glob("#{Courrier.configuration.inbox.destination}/*.html") 20 | .sort_by { -File.basename(_1, ".html").to_i } 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/courrier/previews/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Courrier Inbox 6 | 7 | 110 | 111 | 112 |
113 | 152 | 153 |
154 |
155 |
156 | 157 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "courrier" 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 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | VERSION=$1 4 | 5 | if [ -z "$VERSION" ]; then 6 | echo "Error: The version number is required." 7 | echo "Usage: $0 " 8 | exit 1 9 | fi 10 | 11 | printf "module Courrier\n VERSION = \"$VERSION\"\nend\n" > ./lib/courrier/version.rb 12 | bundle 13 | git add Gemfile.lock lib/courrier/version.rb 14 | git commit -m "Bump version for $VERSION" 15 | git push 16 | git tag v$VERSION 17 | git push --tags 18 | gem build courrier.gemspec 19 | gem push "courrier-$VERSION.gem" 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Courrier::Engine.routes.draw do 4 | resources :previews, only: %w[show], constraints: {id: /.*\.html/} 5 | resource :cleanup, only: %w[create], module: "previews" 6 | 7 | root "previews#index" 8 | end 9 | -------------------------------------------------------------------------------- /courrier.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/courrier/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "courrier" 7 | spec.version = Courrier::VERSION 8 | spec.authors = ["Rails Designer"] 9 | spec.email = ["devs@railsdesigner.com"] 10 | 11 | spec.summary = "API-powered email delivery for Ruby apps" 12 | spec.description = "API-powered email delivery for Ruby apps with support for Postmark, SendGrid, Mailgun and more." 13 | spec.homepage = "https://railsdesigner.com/courrier/" 14 | spec.license = "MIT" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | spec.metadata["source_code_uri"] = "https://github.com/Rails-Designer/courrier/" 18 | 19 | spec.files = Dir["{bin,app,config,lib}/**/*", "Rakefile", "README.md", "courrier.gemspec", "Gemfile", "Gemfile.lock"] 20 | 21 | spec.required_ruby_version = ">= 3.2.0" 22 | 23 | spec.add_dependency "launchy", ">= 3.1", "< 4" 24 | spec.add_dependency "nokogiri", ">= 1.18", "< 2" 25 | end 26 | -------------------------------------------------------------------------------- /lib/courrier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/version" 4 | require "courrier/errors" 5 | require "courrier/configuration" 6 | require "courrier/email" 7 | require "courrier/engine" if defined?(Rails) 8 | require "courrier/railtie" if defined?(Rails) 9 | 10 | module Courrier 11 | end 12 | -------------------------------------------------------------------------------- /lib/courrier/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/configuration/inbox" 4 | require "courrier/configuration/providers" 5 | 6 | module Courrier 7 | class << self 8 | attr_writer :configuration 9 | 10 | def configure 11 | self.configuration ||= Configuration.new 12 | 13 | yield(configuration) if block_given? 14 | end 15 | 16 | def configuration 17 | @configuration ||= Configuration.new 18 | end 19 | end 20 | 21 | class Configuration 22 | attr_accessor :provider, :api_key, :logger, :email_path, :layouts, :default_url_options, :auto_generate_text, 23 | :from, :reply_to, :cc, :bcc 24 | attr_reader :providers, :inbox 25 | 26 | def initialize 27 | @provider = "logger" 28 | @api_key = nil 29 | @logger = ::Logger.new($stdout) 30 | @email_path = default_email_path 31 | 32 | @layouts = nil 33 | @default_url_options = {host: ""} 34 | @auto_generate_text = false 35 | 36 | @from = nil 37 | @reply_to = nil 38 | @cc = nil 39 | @bcc = nil 40 | 41 | @providers = Courrier::Configuration::Providers.new 42 | @inbox = Courrier::Configuration::Inbox.new 43 | end 44 | 45 | private 46 | 47 | def default_email_path 48 | defined?(Rails) ? Rails.root.join("app", "emails").to_s : File.join("courrier", "emails") 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/courrier/configuration/inbox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Configuration 5 | class Inbox 6 | attr_accessor :destination, :auto_open, :template_path 7 | 8 | def initialize 9 | @destination = default_destination 10 | @auto_open = false 11 | @template_path = File.expand_path("../../../courrier/email/providers/inbox/default.html.erb", __FILE__) 12 | end 13 | 14 | private 15 | 16 | def default_destination 17 | defined?(Rails) ? Rails.root.join("tmp", "courrier", "emails").to_s : File.join(Dir.tmpdir, "courrier", "emails") 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/courrier/configuration/providers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Configuration 5 | class Providers 6 | def initialize 7 | @providers = {} 8 | end 9 | 10 | def method_missing(name, *, **) 11 | @providers[name.to_sym] ||= ProviderConfig.new 12 | end 13 | 14 | def respond_to_missing?(name, include_private = false) = true 15 | 16 | def [](provider_name) 17 | @providers[provider_name.to_sym] ||= ProviderConfig.new 18 | end 19 | 20 | def to_h = @providers.transform_values(&:to_h) 21 | end 22 | 23 | class ProviderConfig 24 | def initialize 25 | @options = {} 26 | end 27 | 28 | def method_missing(name, value = nil, **) 29 | option_name = name.to_s.chomp("=").to_sym 30 | 31 | return @options[option_name] = value if name.to_s.end_with?("=") 32 | 33 | @options[name.to_sym] 34 | end 35 | 36 | def respond_to_missing?(name, include_private = false) = true 37 | 38 | def to_h = @options 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/courrier/email.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/email/address" 4 | require "courrier/jobs/email_delivery_job" if defined?(Rails) 5 | require "courrier/email/layouts" 6 | require "courrier/email/options" 7 | require "courrier/email/provider" 8 | 9 | module Courrier 10 | class Email 11 | attr_accessor :provider, :api_key, :default_url_options, :options, :queue_options 12 | 13 | @queue_options = {} 14 | 15 | class << self 16 | %w[provider api_key from reply_to cc bcc layouts default_url_options].each do |attribute| 17 | define_method(attribute) do 18 | instance_variable_get("@#{attribute}") || 19 | (superclass.respond_to?(attribute) ? superclass.send(attribute) : nil) || 20 | Courrier.configuration&.send(attribute) 21 | end 22 | 23 | define_method("#{attribute}=") do |value| 24 | instance_variable_set("@#{attribute}", value) 25 | end 26 | end 27 | 28 | def configure(**options) 29 | options.each { |key, value| send("#{key}=", value) if respond_to?("#{key}=") } 30 | end 31 | alias_method :set, :configure 32 | 33 | def queue_options 34 | @queue_options ||= {} 35 | end 36 | 37 | attr_writer :queue_options 38 | 39 | def enqueue(**options) 40 | self.queue_options = options 41 | end 42 | alias_method :enqueue_with, :enqueue 43 | 44 | def layout(**options) 45 | self.layouts = options 46 | end 47 | 48 | def deliver(**options) 49 | new(options).deliver_now 50 | end 51 | alias_method :deliver_now, :deliver 52 | 53 | def deliver_later(**options) 54 | new(options).deliver_later 55 | end 56 | 57 | def inherited(subclass) 58 | super 59 | 60 | # If you read this and know how to move this Rails-specific logic somewhere 61 | # else, e.g. `lib/courrier/railtie.rb`, open a PR ❤️ 62 | if defined?(Rails) && Rails.application 63 | subclass.include Rails.application.routes.url_helpers 64 | end 65 | end 66 | end 67 | 68 | def initialize(options = {}) 69 | @provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.provider 70 | @api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.api_key 71 | 72 | @default_url_options = self.class.default_url_options.merge(options[:default_url_options] || {}) 73 | @context_options = options.except(:provider, :api_key, :from, :to, :reply_to, :cc, :bcc, :subject, :text, :html) 74 | @options = Email::Options.new( 75 | options.merge( 76 | from: options[:from] || self.class.from || Courrier.configuration&.from, 77 | reply_to: options[:reply_to] || self.class.reply_to || Courrier.configuration&.reply_to, 78 | cc: options[:cc] || self.class.cc || Courrier.configuration&.cc, 79 | bcc: options[:bcc] || self.class.bcc || Courrier.configuration&.bcc, 80 | subject: subject, 81 | text: text, 82 | html: html, 83 | auto_generate_text: Courrier.configuration&.auto_generate_text, 84 | layouts: Courrier::Email::Layouts.new(self).build 85 | ) 86 | ) 87 | end 88 | 89 | def deliver 90 | if delivery_disabled? 91 | Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable" 92 | 93 | return nil 94 | end 95 | 96 | Provider.new( 97 | provider: @provider, 98 | api_key: @api_key, 99 | options: @options, 100 | provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym), 101 | context_options: @context_options 102 | ).deliver 103 | end 104 | alias_method :deliver_now, :deliver 105 | 106 | def deliver_later 107 | if delivery_disabled? 108 | Courrier.configuration&.logger&.info "[Courrier] Email delivery skipped: delivery is disabled via environment variable" 109 | 110 | return nil 111 | end 112 | 113 | data = { 114 | email_class: self.class.name, 115 | provider: @provider, 116 | api_key: @api_key, 117 | options: @options.to_h, 118 | provider_options: Courrier.configuration&.providers&.[](@provider.to_s.downcase.to_sym), 119 | context_options: @context_options 120 | } 121 | 122 | job = Courrier::Jobs::EmailDeliveryJob 123 | job = job.set(**self.class.queue_options) if self.class.queue_options.any? 124 | 125 | job.perform_later(data) 126 | rescue => error 127 | raise Courrier::BackgroundDeliveryError, "Failed to enqueue email: #{error.message}" 128 | end 129 | 130 | private 131 | 132 | def delivery_disabled? 133 | ENV["COURRIER_EMAIL_DISABLED"] == "true" || ENV["COURRIER_EMAIL_ENABLED"] == "false" 134 | end 135 | 136 | def method_missing(name, *) = @context_options[name] 137 | 138 | def respond_to_missing?(name, include_private = false) = true 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/courrier/email/address.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Address 6 | class << self 7 | def with_name(address, name) 8 | raise Courrier::ArgumentError, "Both `address` and `name` are required" if address.nil? || name.nil? 9 | raise Courrier::ArgumentError, "Both `address` and `name` must not be empty" if address.empty? || name.empty? 10 | 11 | address = address.gsub(/[<>]/, "") 12 | formatted_name = format_display_for(name) 13 | 14 | "#{formatted_name} <#{address}>" 15 | end 16 | 17 | private 18 | 19 | def format_display_for(name) 20 | return quote_escaped(name) if special_characters_in?(name) 21 | 22 | name 23 | end 24 | 25 | def quote_escaped(name) = %("#{name.gsub('"', '\\"')}") 26 | 27 | def special_characters_in?(name) 28 | name =~ /[(),.:;<>@\[\]"]/ 29 | end 30 | end 31 | 32 | def email_with_name(email, name) = Address.with_name(email, name) 33 | alias_method :email_address_with_name, :email_with_name 34 | module_function :email_with_name, :email_address_with_name 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/courrier/email/layouts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | class Layouts 6 | def initialize(email) 7 | @email = email 8 | end 9 | 10 | def build 11 | return [] if no_layouts? 12 | 13 | [layouts] 14 | end 15 | 16 | private 17 | 18 | FORMATS = %w[html text] 19 | 20 | def no_layouts? = @email.class.layouts.nil? 21 | 22 | def layouts 23 | FORMATS.map(&:to_sym).to_h do |format| 24 | template = @email.class.layouts[format] 25 | 26 | next if template.nil? 27 | 28 | [format, render(template)] 29 | end 30 | end 31 | 32 | def render(template) 33 | return @email.send(template) if template.is_a?(Symbol) 34 | return template.call if template.is_a?(Class) 35 | 36 | template 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/courrier/email/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/email/transformer" 4 | 5 | module Courrier 6 | class Email 7 | class Options 8 | attr_reader :from, :to, :reply_to, :cc, :bcc, :subject 9 | 10 | def initialize(options = {}) 11 | @from = options.fetch(:from, nil) 12 | 13 | @to = options.fetch(:to, nil) 14 | @reply_to = options.fetch(:reply_to, nil) 15 | @cc = options.fetch(:cc, nil) 16 | @bcc = options.fetch(:bcc, nil) 17 | 18 | @subject = options.fetch(:subject, "") 19 | @text = options.fetch(:text, "") 20 | @html = options.fetch(:html, "") 21 | 22 | @auto_generate_text = options.fetch(:auto_generate_text, false) 23 | 24 | @layouts = Array(options[:layouts]) 25 | 26 | raise Courrier::ArgumentError, "Recipient (`to`) is required" unless @to 27 | raise Courrier::ArgumentError, "Sender (`from`) is required" unless @from 28 | end 29 | 30 | def text = wrap(transformed_text, with_layout: :text) 31 | 32 | def html = wrap(@html, with_layout: :html) 33 | 34 | def to_h 35 | { 36 | from: @from, 37 | to: @to, 38 | reply_to: @reply_to, 39 | cc: @cc, 40 | bcc: @bcc, 41 | subject: @subject, 42 | text: @text, 43 | html: @html, 44 | auto_generate_text: @auto_generate_text, 45 | layouts: @layouts 46 | } 47 | end 48 | 49 | private 50 | 51 | def wrap(content, with_layout:) 52 | return content if content.nil? || content.empty? 53 | 54 | @layouts.reduce(content) do |wrapped, layout_options| 55 | layout = layout_options[with_layout] 56 | 57 | next wrapped if !layout 58 | 59 | layout % {content: wrapped} 60 | end 61 | end 62 | 63 | def transformed_text 64 | return Courrier::Email::Transformer.new(@html).to_text if @auto_generate_text 65 | 66 | @text 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/courrier/email/provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/email/providers/base" 4 | require "courrier/email/providers/inbox" 5 | require "courrier/email/providers/logger" 6 | require "courrier/email/providers/loops" 7 | require "courrier/email/providers/mailgun" 8 | require "courrier/email/providers/mailjet" 9 | require "courrier/email/providers/mailpace" 10 | require "courrier/email/providers/postmark" 11 | require "courrier/email/providers/resend" 12 | require "courrier/email/providers/sendgrid" 13 | require "courrier/email/providers/sparkpost" 14 | require "courrier/email/providers/userlist" 15 | 16 | module Courrier 17 | class Email 18 | class Provider 19 | PROVIDERS = { 20 | inbox: Courrier::Email::Providers::Inbox, 21 | logger: Courrier::Email::Providers::Logger, 22 | loops: Courrier::Email::Providers::Loops, 23 | mailgun: Courrier::Email::Providers::Mailgun, 24 | mailjet: Courrier::Email::Providers::Mailjet, 25 | mailpace: Courrier::Email::Providers::Mailpace, 26 | postmark: Courrier::Email::Providers::Postmark, 27 | resend: Courrier::Email::Providers::Resend, 28 | sendgrid: Courrier::Email::Providers::Sendgrid, 29 | sparkpost: Courrier::Email::Providers::Sparkpost, 30 | userlist: Courrier::Email::Providers::Userlist 31 | } 32 | 33 | def initialize(provider: nil, api_key: nil, options: {}, provider_options: {}, context_options: {}) 34 | @provider = provider 35 | @api_key = api_key 36 | @options = options 37 | @provider_options = provider_options 38 | @context_options = context_options 39 | end 40 | 41 | def deliver 42 | raise Courrier::ConfigurationError, "`provider` and `api_key` must be configured for production environment" if configuration_missing_in_production? 43 | raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.nil? || @provider.empty? 44 | 45 | provider_class.new( 46 | api_key: @api_key, 47 | options: @options, 48 | provider_options: @provider_options, 49 | context_options: @context_options 50 | ).deliver 51 | end 52 | 53 | private 54 | 55 | def configuration_missing_in_production? 56 | production? && required_attributes_blank? 57 | end 58 | 59 | def comma_separated_providers = PROVIDERS.keys.join(", ") 60 | 61 | def provider_class 62 | return PROVIDERS[@provider.to_sym] if PROVIDERS.key?(@provider.to_sym) 63 | 64 | Object.const_get(@provider) 65 | end 66 | 67 | def required_attributes_blank? = @api_key.empty? 68 | 69 | def production? 70 | defined?(Rails) && Rails.env.production? 71 | end 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "courrier/email/request" 4 | 5 | module Courrier 6 | class Email 7 | module Providers 8 | class Base 9 | def initialize(api_key: nil, options: {}, provider_options: {}, context_options: {}) 10 | @api_key = api_key 11 | @options = options 12 | @provider_options = provider_options 13 | @context_options = context_options 14 | end 15 | 16 | def deliver 17 | Request.new( 18 | endpoint_url: endpoint_url, 19 | body: body, 20 | provider: provider, 21 | headers: headers, 22 | content_type: content_type 23 | ).post 24 | end 25 | 26 | def body = raise Courrier::NotImplementedError, "Provider class must implement `body`" 27 | 28 | private 29 | 30 | def endpoint_url = self.class::ENDPOINT_URL 31 | 32 | def content_type = "application/json" 33 | 34 | def headers = {} 35 | 36 | def provider = self.class.name.split("::").last 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/inbox.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "tmpdir" 4 | require "fileutils" 5 | require "launchy" 6 | 7 | module Courrier 8 | class Email 9 | module Providers 10 | class Inbox < Base 11 | def deliver 12 | FileUtils.mkdir_p(config.destination) 13 | 14 | file_path = File.join(config.destination, "#{Time.now.to_i}.html") 15 | 16 | File.write(file_path, ERB.new(File.read(config.template_path)).result(binding)) 17 | 18 | Launchy.open(file_path) if config.auto_open 19 | 20 | "📮 Email saved to #{file_path} and #{email_destination}" 21 | end 22 | 23 | def name = extract(@options.to)[:name] 24 | 25 | def email = extract(@options.to)[:email] 26 | 27 | def text = prepare(@options.text) 28 | 29 | def html = prepare(@options.html) 30 | 31 | private 32 | 33 | def extract(to) 34 | if to.to_s =~ /(.*?)\s*<(.+?)>/ 35 | {name: $1.strip, email: $2.strip} 36 | else 37 | {name: nil, email: to.to_s.strip} 38 | end 39 | end 40 | 41 | def prepare(content) 42 | content.to_s.gsub(URL_PARSER.make_regexp(%w[http https])) do |url| 43 | %(#{url}) 44 | end 45 | end 46 | 47 | def config = @config ||= Courrier.configuration.inbox 48 | 49 | def email_destination 50 | return "opened in your default browser" if config.auto_open 51 | 52 | path = begin 53 | Rails.application.routes.url_helpers.courrier_path 54 | rescue 55 | "/courrier/ (Note: Add `mount Courrier::Engine => \"/courrier\"` to your routes.rb to enable proper routing)" 56 | end 57 | 58 | "available at #{path}" 59 | end 60 | 61 | URL_PARSER = ( 62 | defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER 63 | ) 64 | 65 | class Email < Data.define(:path, :filename, :metadata) 66 | Metadata = Data.define(:to, :subject) 67 | 68 | def self.from_file(path) 69 | content = File.read(path) 70 | json = content[//m, 1] 71 | metadata = JSON.parse(json, symbolize_names: true) 72 | 73 | new( 74 | path: path, 75 | filename: File.basename(path), 76 | metadata: Metadata.new(**metadata) 77 | ) 78 | end 79 | end 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/inbox/default.html.erb: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 11 | 12 | <%= @options.subject %> 13 | 81 | 82 | 83 |
84 | 107 | 108 | 111 | 112 | 124 |
125 | 126 | 127 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | 5 | module Courrier 6 | class Email 7 | module Providers 8 | class Logger < Base 9 | def deliver 10 | logger = Courrier.configuration.logger 11 | logger.formatter = proc do |_severity, _datetime, _progname, message| 12 | "#{format_email_using(message)}\n" 13 | end 14 | 15 | logger.info(@options) 16 | end 17 | 18 | private 19 | 20 | def format_email_using(options) 21 | <<~EMAIL 22 | #{separator} 23 | Timestamp: #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")} 24 | From: #{@options.from} 25 | To: #{@options.to} 26 | Subject: #{@options.subject} 27 | 28 | Text: 29 | #{@options.text || "(empty)"} 30 | 31 | HTML: 32 | #{@options.html || "(empty)"} 33 | #{separator} 34 | EMAIL 35 | end 36 | 37 | def separator = "-" * 80 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/loops.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Loops < Base 7 | ENDPOINT_URL = "https://app.loops.so/api/v1/transactional" 8 | 9 | def body 10 | { 11 | "email" => @options.to, 12 | "transactionalId" => @provider_options.transactional_id || raise(Courrier::ArgumentError, "Loops requires a `transactionalId`"), 13 | "dataVariables" => data_variables 14 | }.compact 15 | end 16 | 17 | private 18 | 19 | def headers 20 | { 21 | "Authorization" => "Bearer #{@api_key}" 22 | } 23 | end 24 | 25 | def data_variables = @context_options.compact 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/mailgun.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Mailgun < Base 7 | ENDPOINT_URL = "https://api.mailgun.net/v3/%{domain}/messages" 8 | 9 | def body 10 | { 11 | "from" => @options.from, 12 | 13 | "to" => @options.to, 14 | "h:Reply-To" => @options.reply_to, 15 | 16 | "subject" => @options.subject, 17 | "text" => @options.text, 18 | "html" => @options.html 19 | }.compact 20 | end 21 | 22 | private 23 | 24 | def endpoint_url 25 | domain = @provider_options.domain || raise(Courrier::ArgumentError, "Mailgun requires a `domain`") 26 | 27 | ENDPOINT_URL % {domain: domain} 28 | end 29 | 30 | def content_type = "multipart/form-data" 31 | 32 | def headers 33 | { 34 | "Authorization" => "Basic #{Base64.strict_encode64("api:#{@api_key}")}" 35 | } 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/mailjet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Mailjet < Base 7 | ENDPOINT_URL = "https://api.mailjet.com/v3.1/send" 8 | 9 | def body 10 | { 11 | "Messages" => [ 12 | { 13 | "From" => { 14 | "Email" => @options.from 15 | }, 16 | 17 | "To" => [ 18 | { 19 | "Email" => @options.to 20 | } 21 | ], 22 | "ReplyTo" => reply_to_object, 23 | 24 | "Subject" => @options.subject, 25 | "TextPart" => @options.text, 26 | "HTMLPart" => @options.html 27 | }.compact 28 | ] 29 | } 30 | end 31 | 32 | private 33 | 34 | def headers 35 | { 36 | "Authorization" => "Basic " + Base64.strict_encode64("#{@api_key}:#{@provider_options.api_secret}") 37 | } 38 | end 39 | 40 | def reply_to_object 41 | return if @options.reply_to.nil? 42 | 43 | { 44 | "Email" => @options.reply_to 45 | } 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/mailpace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Mailpace < Base 7 | ENDPOINT_URL = "https://app.mailpace.com/api/v1/send" 8 | 9 | def body 10 | { 11 | "from" => @options.from, 12 | 13 | "to" => @options.to, 14 | "replyto" => @options.reply_to, 15 | 16 | "subject" => @options.subject, 17 | "textbody" => @options.text, 18 | "htmlbody" => @options.html 19 | }.compact 20 | end 21 | 22 | private 23 | 24 | def headers 25 | { 26 | "MailPace-Server-Token" => @api_key 27 | } 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/postmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Postmark < Base 7 | ENDPOINT_URL = "https://api.postmarkapp.com/email" 8 | 9 | def body 10 | { 11 | "From" => @options.from, 12 | 13 | "To" => @options.to, 14 | "ReplyTo" => @options.reply_to, 15 | 16 | "Subject" => @options.subject, 17 | "TextBody" => @options.text, 18 | "HtmlBody" => @options.html, 19 | 20 | "MessageStream" => @provider_options.message_stream || "outbound" 21 | }.compact 22 | end 23 | 24 | private 25 | 26 | def headers 27 | { 28 | "X-Postmark-Server-Token" => @api_key 29 | } 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/resend.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Resend < Base 7 | ENDPOINT_URL = "https://api.resend.com/emails" 8 | 9 | def body 10 | { 11 | "from" => @options.from, 12 | "to" => @options.to, 13 | "reply_to" => @options.reply_to, 14 | "cc" => @options.cc, 15 | "bcc" => @options.bcc, 16 | "subject" => @options.subject, 17 | "text" => @options.text, 18 | "html" => @options.html 19 | }.compact 20 | end 21 | 22 | private 23 | 24 | def headers 25 | { 26 | "Authorization" => "Bearer #{@api_key}" 27 | } 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/sendgrid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Sendgrid < Base 7 | ENDPOINT_URL = "https://api.sendgrid.com/v3/mail/send" 8 | 9 | def body 10 | { 11 | "from" => { 12 | "email" => @options.from 13 | }, 14 | "personalizations" => [ 15 | { 16 | "to" => [ 17 | { 18 | "email" => @options.to 19 | } 20 | ] 21 | } 22 | ], 23 | "reply_to" => reply_to_object, 24 | 25 | "subject" => @options.subject, 26 | "content" => [ 27 | { 28 | "type" => "text/plain", 29 | "value" => @options.text 30 | }, 31 | { 32 | "type" => "text/html", 33 | "value" => @options.html 34 | } 35 | ].compact 36 | }.compact 37 | end 38 | 39 | private 40 | 41 | def headers 42 | { 43 | "Authorization" => "Bearer #{@api_key}" 44 | } 45 | end 46 | 47 | def reply_to_object 48 | return if @options.reply_to.nil? 49 | 50 | { 51 | "email" => @options.reply_to 52 | } 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/sparkpost.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Sparkpost < Base 7 | ENDPOINT_URL = "https://api.sparkpost.com/api/v1/transmissions" 8 | 9 | def body 10 | { 11 | "content" => { 12 | "reply_to" => @options.reply_to, 13 | "from" => @options.from, 14 | "subject" => @options.subject, 15 | "text" => @options.text, 16 | "html" => @options.html 17 | }.compact, 18 | "recipients" => [ 19 | { 20 | "address" => { 21 | "email" => @options.to 22 | } 23 | } 24 | ] 25 | } 26 | end 27 | 28 | private 29 | 30 | def headers 31 | { 32 | "Authorization" => @api_key 33 | } 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/courrier/email/providers/userlist.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class Userlist < Base 7 | ENDPOINT_URL = "https://push.userlist.com/messages" 8 | 9 | def body 10 | { 11 | "from" => @options.from, 12 | "to" => @options.to, 13 | "subject" => @options.subject, 14 | "body" => body_document 15 | }.compact.merge(provider_options) 16 | end 17 | 18 | private 19 | 20 | def headers 21 | { 22 | "Authorization" => "Push #{@api_key}" 23 | } 24 | end 25 | 26 | def body_document 27 | if @options.html && @options.text 28 | multipart_document 29 | elsif @options.html 30 | html_document 31 | elsif @options.text 32 | text_document 33 | end 34 | end 35 | 36 | def provider_options 37 | {"theme" => nil}.merge(@provider_options) 38 | end 39 | 40 | def multipart_document 41 | { 42 | "type" => "multipart", 43 | "content" => [ 44 | html_document, 45 | text_document 46 | ] 47 | } 48 | end 49 | 50 | def html_document 51 | { 52 | "type" => "html", 53 | "content" => @options.html 54 | } 55 | end 56 | 57 | def text_document 58 | { 59 | "type" => "text", 60 | "content" => @options.text 61 | } 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/courrier/email/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | require "net/http" 5 | require "courrier/email/result" 6 | 7 | module Courrier 8 | class Email 9 | class Request 10 | def initialize(endpoint_url:, body:, provider:, headers: {}, content_type: "application/json") 11 | @endpoint_url = endpoint_url 12 | @body = body 13 | @provider = provider 14 | @headers = headers 15 | @content_type = content_type 16 | end 17 | 18 | def post 19 | uri = URI.parse(@endpoint_url) 20 | request = Net::HTTP::Post.new(uri) 21 | 22 | headers_for request 23 | body_for request 24 | 25 | options = {use_ssl: uri.scheme == "https"} 26 | 27 | begin 28 | response = Net::HTTP.start(uri.hostname, uri.port, options) { _1.request(request) } 29 | 30 | Result.new(response: response) 31 | rescue => error 32 | Result.new(error: error) 33 | end 34 | end 35 | 36 | private 37 | 38 | def headers_for(request) 39 | default_headers.merge(@headers).each do |key, value| 40 | request[key] = value 41 | end 42 | end 43 | 44 | def body_for(request) 45 | return set_multipart_form(request) if requires_multipart_form? 46 | 47 | set_json_body(request) 48 | end 49 | 50 | def default_headers 51 | { 52 | "Content-Type" => @content_type, 53 | "Accept" => "application/json" 54 | } 55 | end 56 | 57 | def requires_multipart_form? 58 | ["Mailgun"].include?(@provider) 59 | end 60 | 61 | def set_multipart_form(request) 62 | request.set_form(@body, "multipart/form-data") 63 | end 64 | 65 | def set_json_body(request) 66 | request.content_type = "application/json" 67 | request.body = JSON.dump(@body) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/courrier/email/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "json" 4 | 5 | module Courrier 6 | class Email 7 | class Result 8 | attr_reader :success, :response, :data, :error 9 | 10 | def initialize(response: nil, error: nil) 11 | @response = response 12 | @error = error 13 | @data = parse_body(@response&.body) 14 | @success = successful? 15 | end 16 | 17 | def success? = @success 18 | 19 | private 20 | 21 | def parse_body(body) 22 | return {} if @response.nil? 23 | 24 | begin 25 | JSON.parse(body) 26 | rescue JSON::ParserError 27 | {} 28 | end 29 | end 30 | 31 | def successful? 32 | return false if response_failed? 33 | return @data["success"] if @data.key?("success") 34 | 35 | (200..299).cover?(status_code) 36 | end 37 | 38 | def response_failed? = @error || @response.nil? 39 | 40 | def status_code = @response.code.to_i 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/courrier/email/transformer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "nokogiri" 4 | 5 | module Courrier 6 | class Email 7 | class Transformer 8 | def initialize(content) 9 | @content = content 10 | end 11 | 12 | def to_text 13 | Nokogiri::HTML(@content) 14 | .then { remove_unwanted_elements(_1) } 15 | .then { process_links(_1) } 16 | .then { preserve_line_breaks(_1) } 17 | .then { clean_up(_1) } 18 | end 19 | 20 | private 21 | 22 | BLOCK_ELEMENTS = %w[p div h1 h2 h3 h4 h5 h6 pre blockquote li] 23 | 24 | def remove_unwanted_elements(html) = html.tap { _1.css("script, style").remove } 25 | 26 | def process_links(html) 27 | html.tap do |document| 28 | document.css("a") 29 | .select { valid?(_1) } 30 | .reject { _1.text.strip.empty? || _1.text.strip == _1["href"] } 31 | .each { _1.content = "#{_1.text.strip} (#{_1["href"]})" } 32 | end 33 | end 34 | 35 | def preserve_line_breaks(html) 36 | html.tap do |document| 37 | document.css(BLOCK_ELEMENTS.join(",")).each { _1.after("\n") } 38 | end 39 | end 40 | 41 | def clean_up(html) = html.text.strip.gsub(/ *\n */m, "\n").squeeze(" \n") 42 | 43 | def valid?(link) = link["href"] && !link["href"].start_with?("#") 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/courrier/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Engine < ::Rails::Engine 5 | isolate_namespace Courrier 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/courrier/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | class Error < StandardError; end 5 | 6 | class ConfigurationError < Error; end 7 | 8 | class ArgumentError < ::ArgumentError; end 9 | 10 | class NotImplementedError < ::NotImplementedError; end 11 | 12 | class BackgroundDeliveryError < StandardError; end 13 | end 14 | -------------------------------------------------------------------------------- /lib/courrier/jobs/email_delivery_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Courrier 4 | module Jobs 5 | class EmailDeliveryJob < ActiveJob::Base 6 | def perform(data) 7 | email_class = data[:email_class].constantize 8 | 9 | email_class.new( 10 | provider: data[:provider], 11 | api_key: data[:api_key], 12 | from: data[:options][:from], 13 | to: data[:options][:to], 14 | reply_to: data[:options][:reply_to], 15 | cc: data[:options][:cc], 16 | bcc: data[:options][:bcc], 17 | provider_options: data[:provider_options], 18 | context_options: data[:context_options] 19 | ).deliver_now 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/courrier/railtie.rb: -------------------------------------------------------------------------------- 1 | module Courrier 2 | class Railtie < Rails::Railtie 3 | config.after_initialize do 4 | Courrier::Email.default_url_options = Courrier.configuration.default_url_options 5 | end 6 | 7 | ActiveSupport.on_load(:action_view) do 8 | include Courrier::Email::Address 9 | end 10 | 11 | ActiveSupport.on_load(:action_controller) do 12 | include Courrier::Email::Address 13 | end 14 | 15 | ActiveSupport.on_load(:active_job) do 16 | include Courrier::Email::Address 17 | end 18 | 19 | rake_tasks do 20 | load File.expand_path("../tasks/courrier.rake", __FILE__) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/courrier/tasks/courrier.rake: -------------------------------------------------------------------------------- 1 | namespace :tmp do 2 | task :courrier do 3 | rm_rf Dir["#{Courrier.configuration.inbox.destination}/[^.]*"], verbose: false 4 | end 5 | 6 | task clear: :courrier 7 | end 8 | 9 | namespace :courrier do 10 | desc "Clear email files from `#{Courrier.configuration.inbox.destination}`" 11 | 12 | task clear: "tmp:courrier" 13 | end 14 | -------------------------------------------------------------------------------- /lib/courrier/version.rb: -------------------------------------------------------------------------------- 1 | module Courrier 2 | VERSION = "0.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/generators/courrier/email_generator.rb: -------------------------------------------------------------------------------- 1 | module Courrier 2 | class EmailGenerator < Rails::Generators::NamedBase 3 | AVAILABLE_TEMPLATES = %w[welcome password_reset] 4 | 5 | desc "Create a new Courrier Email class" 6 | 7 | source_root File.expand_path("templates", __dir__) 8 | 9 | check_class_collision suffix: "Email" 10 | 11 | class_option :skip_suffix, type: :boolean, default: false 12 | class_option :template, type: :string, desc: "Template type (#{AVAILABLE_TEMPLATES.join(", ")})" 13 | 14 | def copy_mailer_file 15 | template template_file, destination_path 16 | end 17 | 18 | private 19 | 20 | def file_name = super.delete_suffix("_email") 21 | 22 | def parent_class = defined?(ApplicationEmail) ? ApplicationEmail : Courrier::Email 23 | 24 | def template_file 25 | if options[:template] && template_exists?("email/#{options[:template]}.rb.tt") 26 | "email/#{options[:template]}.rb.tt" 27 | else 28 | "email.rb.tt" 29 | end 30 | end 31 | 32 | def destination_path 33 | File.join(Courrier.configuration.email_path, class_path, "#{file_name}#{options[:skip_suffix] ? "" : "_email"}.rb") 34 | end 35 | 36 | def template_exists?(path) 37 | find_in_source_paths(path) 38 | rescue 39 | nil 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/generators/courrier/install_generator.rb: -------------------------------------------------------------------------------- 1 | module Courrier 2 | class InstallGenerator < Rails::Generators::Base 3 | desc "Creates the initializer for Courrier" 4 | 5 | source_root File.expand_path("templates", __dir__) 6 | 7 | def copy_initializer_file 8 | template "initializer.rb", "config/initializers/courrier.rb" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/courrier/templates/email.rb.tt: -------------------------------------------------------------------------------- 1 | class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %> 2 | def subject = "" 3 | 4 | # Create HTML and text emails using: 5 | # https://railsdesigner.com/minimal-email-editor/ 6 | def text 7 | <<~TEXT 8 | TEXT 9 | end 10 | 11 | def html 12 | <<~HTML 13 | HTML 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/generators/courrier/templates/email/password_reset.rb.tt: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 3 | # PasswordResetEmail.deliver to: user.email, reset_url: edit_password_url(user.password_reset_token) 4 | # 5 | class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %> 6 | def subject = "Reset your password" 7 | 8 | def text 9 | <<~TEXT 10 | You can reset your password within the next 15 minutes on this password reset page: 11 | #{reset_url} 12 | 13 | If you didn't request a password reset, please ignore this email. 14 | TEXT 15 | end 16 | 17 | def html 18 | <<~HTML 19 |

20 | You can reset your password within the next 15 minutes on 21 | this password reset page. 22 |

23 | 24 |

25 | If you didn't request a password reset, please ignore this email. 26 |

27 | HTML 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/generators/courrier/templates/email/welcome.rb.tt: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # 3 | # WelcomeEmail.deliver to: user.email, name: "John", login_url: "https://example.com/login" 4 | # 5 | class <%= class_name %><%= options[:skip_suffix] ? "" : "Email" %> < <%= parent_class %> 6 | def subject = "Welcome to My First App, #{name}!" 7 | 8 | def text 9 | <<~TEXT 10 | Welcome, #{name}! 11 | 12 | We're excited to have you on board. Here's how to get started: 13 | 14 | 1. Log in to your account: #{login_url} 15 | 2. Complete your profile 16 | 3. Explore our features 17 | 18 | If you have any questions, our help center is available to assist you. 19 | 20 | Thanks for joining us! 21 | TEXT 22 | end 23 | 24 | def html 25 | <<~HTML 26 |

Welcome, #{name}!

27 | 28 |

We're excited to have you on board. Here's how to get started:

29 | 30 |
    31 |
  1. Log in to your account
  2. 32 |
  3. Complete your profile
  4. 33 |
  5. Explore our features
  6. 34 |
35 | 36 |

If you have any questions, our help center is available to assist you.

37 | 38 |

39 | Thanks for joining us! 40 |

41 | HTML 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/generators/courrier/templates/initializer.rb.tt: -------------------------------------------------------------------------------- 1 | Courrier.configure do |config| 2 | include Courrier::Email::Address 3 | 4 | # Choose your email delivery provider 5 | # Default: `logger` 6 | # config.provider = "" # choose from: <%= Courrier::Email::Provider::PROVIDERS.keys.join(", ") %> 7 | 8 | # Add your email provider's API key 9 | # config.api_key = "" 10 | 11 | # Configure provider-specific settings 12 | # config.providers.loops.transactional_id = "" 13 | # config.providers.mailgun.domain = "" 14 | 15 | 16 | # Set default sender details 17 | config.from = email_with_name("support@example.com", "Example Support") # => `Example Support ` 18 | # config.reply_to = "" 19 | # config.cc = "" 20 | # config.bcc = "" 21 | 22 | 23 | # Set host for Rails URL helpers, e.g. `{host: "https://railsdesigner.com/"}` 24 | # config.default_url_options = {} 25 | 26 | # Generate text version from HTML content 27 | # Default: `false` 28 | # config.auto_generate_text = false 29 | 30 | # Location for generated Courrier Emails 31 | # Default: `app/emails` 32 | # config.email_path = "" 33 | 34 | 35 | # Select logger for the `logger` provider 36 | # Default: `::Logger.new($stdout)` - Ruby's built-in Logger 37 | # config.logger = "" 38 | end 39 | -------------------------------------------------------------------------------- /test/courrier/email/address_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "courrier/email/address" 3 | 4 | class Courrier::Email::AddressTest < Minitest::Test 5 | def test_with_name_basic_formatting 6 | assert_equal "Rails Designer ", 7 | Courrier::Email::Address.with_name("devs@railsdesigner.com", "Rails Designer") 8 | end 9 | 10 | def test_with_name_requires_address 11 | error = assert_raises(Courrier::ArgumentError) do 12 | Courrier::Email::Address.with_name(nil, "Rails Designer") 13 | end 14 | assert_equal "Both `address` and `name` are required", error.message 15 | end 16 | 17 | def test_with_name_requires_name 18 | error = assert_raises(Courrier::ArgumentError) do 19 | Courrier::Email::Address.with_name("devs@railsdesigner.com", nil) 20 | end 21 | assert_equal "Both `address` and `name` are required", error.message 22 | end 23 | 24 | def test_with_name_requires_non_empty_address 25 | error = assert_raises(Courrier::ArgumentError) do 26 | Courrier::Email::Address.with_name("", "Rails Designer") 27 | end 28 | assert_equal "Both `address` and `name` must not be empty", error.message 29 | end 30 | 31 | def test_with_name_requires_non_empty_name 32 | error = assert_raises(Courrier::ArgumentError) do 33 | Courrier::Email::Address.with_name("devs@railsdesigner.com", "") 34 | end 35 | assert_equal "Both `address` and `name` must not be empty", error.message 36 | end 37 | 38 | def test_with_name_quotes_special_characters 39 | assert_equal "\"Doe, John\" ", 40 | Courrier::Email::Address.with_name("devs@railsdesigner.com", "Doe, John") 41 | end 42 | 43 | def test_with_name_escapes_quotes 44 | assert_equal "\"John \\\"Johnny\\\" Doe\" ", 45 | Courrier::Email::Address.with_name("devs@railsdesigner.com", 'John "Johnny" Doe') 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/courrier/email/layouts_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::Email::LayoutsTest < Minitest::Test 4 | def test_without_layouts 5 | email = TestEmail.new( 6 | from: "devs@railsdesigner.com", 7 | to: "recipient@railsdesigner.com" 8 | ) 9 | 10 | assert_equal [], Courrier::Email::Layouts.new(email).build 11 | end 12 | 13 | def test_string_layouts 14 | email = TestEmailWithStringLayouts.new( 15 | from: "devs@railsdesigner.com", 16 | to: "recipient@railsdesigner.com" 17 | ) 18 | 19 | expected = [{ html: "
test
", text: "test" }] 20 | 21 | assert_equal expected, Courrier::Email::Layouts.new(email).build 22 | end 23 | 24 | def test_mixed_layouts 25 | email = TestEmailWithLayouts.new( 26 | from: "devs@railsdesigner.com", 27 | to: "recipient@railsdesigner.com" 28 | ) 29 | 30 | expected = [{ html: "

Test HTML Body

", text: "Test Text Body" }] 31 | 32 | assert_equal expected, Courrier::Email::Layouts.new(email).build 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/courrier/email/provider_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "courrier/email/provider" 3 | 4 | class TestCourrierEmailProvider < Minitest::Test 5 | def setup 6 | Object.send(:remove_const, :Rails) if defined?(Rails) 7 | end 8 | 9 | def test_raises_error_for_empty_provider 10 | provider = Courrier::Email::Provider.new( 11 | provider: "", 12 | api_key: "test-key" 13 | ) 14 | 15 | error = assert_raises(Courrier::ConfigurationError) do 16 | provider.deliver 17 | end 18 | 19 | assert_match "Unknown provider", error.message 20 | end 21 | 22 | def test_raises_error_for_nil_provider 23 | provider = Courrier::Email::Provider.new( 24 | provider: nil, 25 | api_key: "test-key" 26 | ) 27 | 28 | error = assert_raises(Courrier::ConfigurationError) do 29 | provider.deliver 30 | end 31 | 32 | assert_match "Unknown provider", error.message 33 | end 34 | 35 | def test_raises_error_in_production_without_config 36 | stub_rails_production 37 | 38 | provider = Courrier::Email::Provider.new( 39 | provider: "mailgun", 40 | api_key: "" 41 | ) 42 | 43 | error = assert_raises(Courrier::ConfigurationError) do 44 | provider.deliver 45 | end 46 | 47 | assert_equal "`provider` and `api_key` must be configured for production environment", error.message 48 | end 49 | 50 | def test_initializes_correct_provider_class 51 | provider_classes = { 52 | inbox: Courrier::Email::Providers::Inbox, 53 | logger: Courrier::Email::Providers::Logger, 54 | loops: Courrier::Email::Providers::Loops, 55 | mailgun: Courrier::Email::Providers::Mailgun, 56 | mailjet: Courrier::Email::Providers::Mailjet, 57 | mailpace: Courrier::Email::Providers::Mailpace, 58 | postmark: Courrier::Email::Providers::Postmark, 59 | sendgrid: Courrier::Email::Providers::Sendgrid, 60 | sparkpost: Courrier::Email::Providers::Sparkpost 61 | } 62 | 63 | provider_classes.each do |provider_name, provider_class| 64 | provider_instance = Courrier::Email::Provider.new( 65 | provider: provider_name.to_s, 66 | api_key: "test-key", 67 | options: { test: true }, 68 | provider_options: { custom: "value" } 69 | ) 70 | 71 | mock_provider = Minitest::Mock.new 72 | 73 | mock_provider.expect(:deliver, nil) 74 | 75 | provider_class.stub :new, mock_provider do 76 | provider_instance.deliver 77 | end 78 | 79 | mock_provider.verify 80 | end 81 | end 82 | 83 | def test_passes_correct_arguments_to_provider 84 | mock_provider = Minitest::Mock.new 85 | 86 | mock_provider.expect( 87 | :deliver, 88 | nil 89 | ) 90 | 91 | Courrier::Email::Providers::Mailgun.stub :new, mock_provider do 92 | provider = Courrier::Email::Provider.new( 93 | provider: "mailgun", 94 | api_key: "test-key", 95 | options: { test: true }, 96 | provider_options: { custom: "value" } 97 | ) 98 | 99 | provider.deliver 100 | end 101 | 102 | mock_provider.verify 103 | end 104 | 105 | def test_accepts_custom_provider_class_name 106 | custom_class = Class.new(Courrier::Email::Providers::Base) do 107 | def deliver; end 108 | end 109 | 110 | Object.const_set(:CustomTestProvider, custom_class) 111 | 112 | provider = Courrier::Email::Provider.new(provider: "CustomTestProvider") 113 | 114 | provider.deliver 115 | ensure 116 | Object.send(:remove_const, :CustomTestProvider) 117 | end 118 | 119 | private 120 | 121 | def stub_rails_production 122 | rails = Module.new 123 | environment = Minitest::Mock.new 124 | 125 | environment.expect(:production?, true) 126 | rails.define_singleton_method(:env) { environment } 127 | 128 | Object.const_set(:Rails, rails) 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /test/courrier/email/providers/inbox_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "courrier/email/providers/inbox" 3 | 4 | class Courrier::Email::Providers::InboxTest < Minitest::Test 5 | include TestEmailHelpers 6 | 7 | def setup 8 | reset_configuration 9 | end 10 | 11 | def test_extract_name_and_email_from_full_address 12 | email = TestEmail.new( 13 | from: "devs@railsdesigner.com", 14 | to: "Rails Designer Devs " 15 | ) 16 | inbox = Courrier::Email::Providers::Inbox.new(options: email.options) 17 | 18 | assert_equal "Rails Designer Devs", inbox.name 19 | assert_equal "devs@railsdesigner.com", inbox.email 20 | end 21 | 22 | def test_extract_email_only 23 | email = TestEmail.new( 24 | from: "devs@railsdesigner.com", 25 | to: "recipient@railsdesigner.com" 26 | ) 27 | inbox = Courrier::Email::Providers::Inbox.new(options: email.options) 28 | 29 | assert_nil inbox.name 30 | assert_equal "recipient@railsdesigner.com", inbox.email 31 | end 32 | 33 | def test_prepare_content_with_urls 34 | Courrier.configure do |config| 35 | config.default_url_options = { host: "https://railsdesigner.com" } 36 | end 37 | 38 | email = TestEmailWithUrl.new( 39 | from: "devs@railsdesigner.com", 40 | to: "recipient@railsdesigner.com" 41 | ) 42 | inbox = Courrier::Email::Providers::Inbox.new(options: email.options) 43 | 44 | assert_includes inbox.text, 'https://railsdesigner.com' 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/courrier/email/providers/logger_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | module Courrier::Email::Providers 4 | class LoggerTest < Minitest::Test 5 | include TestEmailHelpers 6 | 7 | def test_formats_email_for_logging 8 | email = TestEmail.new( 9 | from: "devs@railsdesigner.com", 10 | to: "recipient@railsdesigner.com" 11 | ) 12 | logger = Logger.new(options: email.options) 13 | 14 | output = capture_logger_output { logger.deliver } 15 | 16 | assert_includes output, "From: devs@railsdesigner.com" 17 | assert_includes output, "To: recipient@railsdesigner.com" 18 | assert_includes output, "Subject: Test Subject" 19 | assert_includes output, "Text:\nTest Body" 20 | assert_includes output, "HTML:\n

Test HTML Body

" 21 | end 22 | 23 | private 24 | 25 | def capture_logger_output 26 | output = StringIO.new 27 | original_logger = Courrier.configuration.logger 28 | 29 | Courrier.configuration.logger = ::Logger.new(output) 30 | 31 | yield 32 | 33 | output.string 34 | ensure 35 | Courrier.configuration.logger = original_logger 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/courrier/email/result_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::Email::ResultTest < Minitest::Test 4 | def test_initialize_with_success_response 5 | response = Data.define(:code, :body).new( 6 | code: "200", 7 | body: '{"success": true, "message_id": "test-123"}' 8 | ) 9 | result = Courrier::Email::Result.new(response: response) 10 | 11 | assert result.success? 12 | assert_equal({"success" => true, "message_id" => "test-123"}, result.data) 13 | end 14 | 15 | def test_initialize_with_failure_response 16 | response = Data.define(:code, :body).new( 17 | code: "400", 18 | body: '{"success": false, "error": "Bad request"}' 19 | ) 20 | result = Courrier::Email::Result.new(response: response) 21 | 22 | refute result.success? 23 | assert_equal({"success" => false, "error" => "Bad request"}, result.data) 24 | end 25 | 26 | def test_initialize_with_error 27 | error = StandardError.new("Connection error") 28 | result = Courrier::Email::Result.new(error: error) 29 | 30 | refute result.success? 31 | assert_equal({}, result.data) 32 | end 33 | 34 | def test_success_predicate_method 35 | result = Courrier::Email::Result.new( 36 | response: Data.define(:code, :body).new(code: "200", body: "{}") 37 | ) 38 | assert result.success? 39 | 40 | result = Courrier::Email::Result.new( 41 | response: Data.define(:code, :body).new(code: "500", body: "{}") 42 | ) 43 | refute result.success? 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/courrier/email/transformer_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class TransformerTest < Minitest::Test 4 | def test_removes_unwanted_elements 5 | html = <<~HTML 6 | 7 | 8 |

Actual content

9 | HTML 10 | 11 | assert_equal "Actual content", transform(html) 12 | end 13 | 14 | def test_processes_links 15 | html = <<~HTML 16 | Example 17 | Skip me 18 | HTML 19 | 20 | assert_equal "Example (https://railsdesigner.com)\nSkip me", transform(html) 21 | end 22 | 23 | def test_preserves_line_breaks 24 | html = <<~HTML 25 |
First

Second

26 | HTML 27 | 28 | assert_equal "First\nSecond", transform(html) 29 | end 30 | 31 | def test_preserves_line_breaks_with_proper_html 32 | html = <<~HTML 33 |
34 | First 35 |
36 | 37 |

38 | Second 39 |

40 | HTML 41 | 42 | assert_equal "First\nSecond", transform(html) 43 | end 44 | 45 | def test_no_add_extra_line_breaks_between_block_elements 46 | html = <<~HTML 47 |
First
Second
48 | HTML 49 | 50 | assert_equal "First\nSecond", transform(html) 51 | end 52 | 53 | def test_clean_up 54 | html = <<~HTML 55 |
Multiple spaces
56 |

And newlines

57 | HTML 58 | 59 | assert_equal "Multiple spaces\nAnd newlines", transform(html) 60 | end 61 | 62 | def test_handles_empty_links 63 | html = <<~HTML 64 | 65 | https://railsdesigner.com 66 | HTML 67 | 68 | assert_equal "https://railsdesigner.com", transform(html) 69 | end 70 | 71 | private 72 | 73 | def transform(html) 74 | Courrier::Email::Transformer.new(html).to_text 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/courrier/email_configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::EmailConfigurationTest < Minitest::Test 4 | include TestEmailHelpers 5 | 6 | def setup 7 | reset_test_email_class 8 | reset_configuration 9 | end 10 | 11 | def test_initialization_with_class_defaults 12 | TestEmail.configure( 13 | provider: "class_provider", 14 | api_key: "class_key", 15 | from: "devs@railsdesigner.com", 16 | reply_to: "class_reply@railsdesigner.com", 17 | cc: "class_cc@railsdesigner.com", 18 | bcc: "class_bcc@railsdesigner.com" 19 | ) 20 | 21 | email = TestEmail.new(to: "recipient@railsdesigner.com") 22 | 23 | assert_equal "class_provider", email.provider 24 | assert_equal "class_key", email.api_key 25 | assert_equal "recipient@railsdesigner.com", email.options.to 26 | assert_equal "devs@railsdesigner.com", email.options.from 27 | assert_equal "class_reply@railsdesigner.com", email.options.reply_to 28 | assert_equal "class_cc@railsdesigner.com", email.options.cc 29 | assert_equal "class_bcc@railsdesigner.com", email.options.bcc 30 | end 31 | 32 | def test_initialization_with_configuration_defaults 33 | Courrier.configure do |config| 34 | config.provider = "config_provider" 35 | config.api_key = "config_key" 36 | config.from = "devs@railsdesigner.com" 37 | config.reply_to = "config_reply@railsdesigner.com" 38 | config.cc = "config_cc@railsdesigner.com" 39 | config.bcc = "config_bcc@railsdesigner.com" 40 | end 41 | 42 | email = TestEmail.new(to: "recipient@railsdesigner.com") 43 | 44 | assert_equal "config_provider", email.provider 45 | assert_equal "config_key", email.api_key 46 | assert_equal "recipient@railsdesigner.com", email.options.to 47 | assert_equal "devs@railsdesigner.com", email.options.from 48 | assert_equal "config_reply@railsdesigner.com", email.options.reply_to 49 | assert_equal "config_cc@railsdesigner.com", email.options.cc 50 | assert_equal "config_bcc@railsdesigner.com", email.options.bcc 51 | end 52 | 53 | def test_instance_options_without_config 54 | email = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 55 | 56 | assert_equal "logger", email.provider 57 | assert_equal "recipient@railsdesigner.com", email.options.to 58 | assert_equal "devs@railsdesigner.com", email.options.from 59 | end 60 | 61 | def test_instance_options_override_class_defaults 62 | TestEmail.configure(provider: "class_provider") 63 | 64 | email = TestEmail.new(provider: "instance_provider", from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 65 | 66 | assert_equal "instance_provider", email.provider 67 | end 68 | 69 | def test_class_defaults_override_configuration 70 | Courrier.configure { _1.provider = "config_provider" } 71 | 72 | TestEmail.configure(provider: "class_provider") 73 | 74 | email = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 75 | 76 | assert_equal "class_provider", email.provider 77 | end 78 | 79 | def test_class_defaults_set_configuration 80 | Courrier.configure { _1.provider = "config_provider" } 81 | 82 | TestEmail.set(provider: "class_provider") 83 | 84 | email = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 85 | 86 | assert_equal "class_provider", email.provider 87 | end 88 | 89 | def test_configuration_used_when_no_class_defaults 90 | Courrier.configure { _1.provider = "config_provider" } 91 | 92 | email = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 93 | 94 | assert_equal "config_provider", email.provider 95 | end 96 | 97 | def test_provider_specific_options_from_configuration 98 | Courrier.configure do |config| 99 | config.providers.mailgun.domain = "emails.railsdesigner.com" 100 | config.providers.mailgun.tracking = true 101 | end 102 | end 103 | 104 | def test_inbox_default_configuration 105 | assert_equal File.join(Dir.tmpdir, "courrier", "emails"), Courrier.configuration.inbox.destination 106 | 107 | refute Courrier.configuration.inbox.auto_open 108 | end 109 | 110 | def test_inbox_configuration_can_be_customized 111 | Courrier.configure do |config| 112 | config.inbox.destination = "/custom/path" 113 | config.inbox.auto_open = false 114 | end 115 | 116 | assert_equal "/custom/path", Courrier.configuration.inbox.destination 117 | 118 | refute Courrier.configuration.inbox.auto_open 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/courrier/email_delivery_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::EmailDeliveryTest < Minitest::Test 4 | include TestEmailHelpers 5 | 6 | def setup 7 | reset_test_email_class 8 | reset_configuration 9 | end 10 | 11 | def test_deliver_now_calls_provider_deliver 12 | provider_mock = Minitest::Mock.new 13 | provider_mock.expect(:deliver, "delivery_result") 14 | 15 | Courrier::Email::Provider.stub :new, provider_mock do 16 | result = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com").deliver_now 17 | 18 | assert_equal "delivery_result", result 19 | end 20 | 21 | provider_mock.verify 22 | end 23 | 24 | def test_class_deliver_now_creates_instance_and_delivers 25 | email_mock = Minitest::Mock.new 26 | email_mock.expect(:deliver_now, "delivery_result") 27 | 28 | TestEmail.stub :new, email_mock do 29 | result = TestEmail.deliver_now(to: "recipient@railsdesigner.com") 30 | 31 | assert_equal "delivery_result", result 32 | end 33 | 34 | email_mock.verify 35 | end 36 | 37 | def test_mailgun_provider_options 38 | Courrier.configure do |config| 39 | config.providers.mailgun.domain = "railsdesigner.com" 40 | 41 | config.providers.postmark.enable_tracking = true 42 | config.providers.loops.transactional_id = "tr-1234" 43 | end 44 | 45 | email = TestEmail.new( 46 | provider: "mailgun", 47 | from: "devs@railsdesigner.com", 48 | to: "recipient@railsdesigner.com" 49 | ) 50 | 51 | options_captured = nil 52 | mock_provider = create_mock_provider 53 | 54 | Courrier::Email::Provider.stub :new, ->(options) { options_captured = options; mock_provider } do 55 | email.deliver_now 56 | end 57 | 58 | assert_equal "railsdesigner.com", options_captured[:provider_options].domain 59 | assert_nil options_captured[:provider_options].enable_tracking 60 | assert_nil options_captured[:provider_options].transactional_id 61 | end 62 | 63 | def test_postmark_provider_options 64 | Courrier.configure do |config| 65 | config.providers.postmark.enable_tracking = true 66 | 67 | config.providers.mailgun.domain = "railsdesigner.com" 68 | config.providers.loops.transactional_id = "tr-1234" 69 | end 70 | 71 | email = TestEmail.new( 72 | provider: "postmark", 73 | from: "devs@railsdesigner.com", 74 | to: "recipient@railsdesigner.com" 75 | ) 76 | 77 | options_captured = nil 78 | mock_provider = create_mock_provider 79 | 80 | Courrier::Email::Provider.stub :new, ->(options) { options_captured = options; mock_provider } do 81 | email.deliver_now 82 | end 83 | 84 | assert_equal true, options_captured[:provider_options].enable_tracking 85 | assert_nil options_captured[:provider_options].domain 86 | assert_nil options_captured[:provider_options].transactional_id 87 | end 88 | 89 | def test_loops_provider_options 90 | Courrier.configure do |config| 91 | config.providers.loops.transactional_id = "tr-1234" 92 | 93 | config.providers.mailgun.domain = "railsdesigner.com" 94 | config.providers.postmark.enable_tracking = true 95 | end 96 | 97 | email = TestEmail.new( 98 | provider: "loops", 99 | from: "devs@railsdesigner.com", 100 | to: "recipient@railsdesigner.com" 101 | ) 102 | 103 | options_captured = nil 104 | mock_provider = create_mock_provider 105 | 106 | Courrier::Email::Provider.stub :new, ->(options) { options_captured = options; mock_provider } do 107 | email.deliver_now 108 | end 109 | 110 | assert_equal "tr-1234", options_captured[:provider_options].transactional_id 111 | assert_nil options_captured[:provider_options].domain 112 | assert_nil options_captured[:provider_options].enable_tracking 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /test/courrier/email_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::EmailTest < Minitest::Test 4 | include TestEmailHelpers 5 | 6 | def setup 7 | reset_test_email_class 8 | reset_configuration 9 | end 10 | 11 | def test_initialization_with_options 12 | email = TestEmail.new( 13 | provider: "test_provider", 14 | api_key: "test_key", 15 | to: "recipient@railsdesigner.com", 16 | from: "devs@railsdesigner.com", 17 | reply_to: "reply@railsdesigner.com", 18 | cc: "cc@railsdesigner.com", 19 | bcc: "bcc@railsdesigner.com" 20 | ) 21 | 22 | assert_equal "test_provider", email.provider 23 | assert_equal "test_key", email.api_key 24 | assert_equal "recipient@railsdesigner.com", email.options.to 25 | assert_equal "devs@railsdesigner.com", email.options.from 26 | assert_equal "reply@railsdesigner.com", email.options.reply_to 27 | assert_equal "cc@railsdesigner.com", email.options.cc 28 | assert_equal "bcc@railsdesigner.com", email.options.bcc 29 | end 30 | 31 | def test_enqueue_sets_queue_options 32 | TestEmail.enqueue(queue: "emails", wait: 300) 33 | 34 | assert_equal({queue: "emails", wait: 300}, TestEmail.queue_options) 35 | end 36 | 37 | def test_abstract_methods_raise_error 38 | email = Courrier::Email.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 39 | 40 | assert_nil email.subject 41 | 42 | assert_nil email.text 43 | 44 | assert_nil email.html 45 | end 46 | 47 | def test_deliver_alias_for_deliver_now 48 | email = TestEmail.new(from: "devs@railsdesigner.com", to: "recipient@railsdesigner.com") 49 | 50 | assert_equal email.method(:deliver_now), email.method(:deliver) 51 | assert_equal TestEmail.method(:deliver_now), TestEmail.method(:deliver) 52 | end 53 | 54 | def test_access_context_options_in_email_content 55 | email = TestEmailWithContext.new( 56 | to: "recipient@railsdesigner.com", 57 | from: "devs@railsdesigner.com", 58 | order_id: "123", 59 | token: "abc456" 60 | ) 61 | 62 | assert_equal "Order 123", email.subject 63 | assert_equal "Test order 123 with token abc456", email.text 64 | end 65 | 66 | def test_missing_context_option_returns_nil 67 | email = TestEmailWithContext.new( 68 | to: "recipient@railsdesigner.com", 69 | from: "devs@railsdesigner.com" 70 | ) 71 | 72 | assert_nil email.order_id 73 | assert_equal "Order ", email.subject 74 | end 75 | 76 | def test_url_generation_with_configured_host 77 | Courrier.configure do |config| 78 | config.default_url_options = { host: "railsdesigner.com" } 79 | end 80 | 81 | email = TestEmailWithUrl.new( 82 | to: "recipient@railsdesigner.com", 83 | from: "devs@railsdesigner.com" 84 | ) 85 | 86 | assert_equal "Click here: railsdesigner.com", email.text 87 | end 88 | 89 | def test_url_generation_with_missing_host 90 | Courrier.configure do |config| 91 | config.default_url_options = { host: "" } 92 | end 93 | 94 | email = TestEmailWithUrl.new( 95 | to: "recipient@railsdesigner.com", 96 | from: "devs@railsdesigner.com" 97 | ) 98 | 99 | assert_equal "Click here: ", email.text 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/courrier/providers/base_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class Courrier::Email::Providers::BaseTest < Minitest::Test 4 | def setup 5 | @provider = Courrier::Email::Providers::TestProvider.new 6 | end 7 | 8 | def test_initialize_sets_options 9 | provider = Courrier::Email::Providers::Base.new( 10 | api_key: "test_key", 11 | options: { from: "devs@railsdesigner.com" }, 12 | provider_options: { custom: "value" } 13 | ) 14 | 15 | assert_equal "test_key", provider.instance_variable_get(:@api_key) 16 | assert_equal({ from: "devs@railsdesigner.com" }, provider.instance_variable_get(:@options)) 17 | assert_equal({ custom: "value" }, provider.instance_variable_get(:@provider_options)) 18 | end 19 | 20 | def test_body_raises_not_implemented_error 21 | provider = Courrier::Email::Providers::Base.new 22 | 23 | assert_raises NotImplementedError do 24 | provider.body 25 | end 26 | end 27 | 28 | def test_deliver_returns_result 29 | response_mock = Object.new 30 | 31 | Net::HTTP.stub :start, response_mock do 32 | result = @provider.deliver 33 | 34 | assert_instance_of Courrier::Email::Result, result 35 | end 36 | end 37 | 38 | def test_deliver_handles_errors 39 | error = StandardError.new("Test error") 40 | 41 | Net::HTTP.stub :start, -> (*) { raise error } do 42 | result = @provider.deliver 43 | 44 | assert_instance_of Courrier::Email::Result, result 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/fixtures/email_with_string_layouts.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email" 2 | 3 | class TestEmailWithStringLayouts < Courrier::Email 4 | layout html: "
test
", text: "test" 5 | end 6 | -------------------------------------------------------------------------------- /test/fixtures/test_email.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email" 2 | 3 | class TestEmail < Courrier::Email 4 | def subject 5 | "Test Subject" 6 | end 7 | 8 | def html 9 | "

Test HTML Body

" 10 | end 11 | 12 | def text 13 | "Test Body" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/test_email_with_context.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email" 2 | 3 | class TestEmailWithContext < Courrier::Email 4 | def subject 5 | "Order #{order_id}" 6 | end 7 | 8 | def text 9 | "Test order #{order_id} with token #{token}" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/test_email_with_mixed_layouts.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email" 2 | 3 | class TestEmailWithLayouts < Courrier::Email 4 | class HtmlLayout 5 | def self.call 6 | "

Test HTML Body

" 7 | end 8 | end 9 | 10 | layout html: HtmlLayout, text: :text_layout 11 | 12 | def text_layout 13 | "Test Text Body" 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/fixtures/test_email_with_url.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email" 2 | 3 | class TestEmailWithUrl < Courrier::Email 4 | def subject 5 | "Email with URL" 6 | end 7 | 8 | def text 9 | "Click here: #{default_url_options[:host]}" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/fixtures/test_provider.rb: -------------------------------------------------------------------------------- 1 | require "courrier/email/provider" 2 | 3 | module Courrier 4 | class Email 5 | module Providers 6 | class TestProvider < Base 7 | ENDPOINT_URL = "https://railsdesigner.com/api" 8 | 9 | def body 10 | {test: "data"} 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/test_email_helpers.rb: -------------------------------------------------------------------------------- 1 | module TestEmailHelpers 2 | def reset_test_email_class 3 | TestEmail.provider = nil 4 | TestEmail.api_key = nil 5 | TestEmail.from = nil 6 | TestEmail.reply_to = nil 7 | TestEmail.cc = nil 8 | TestEmail.bcc = nil 9 | end 10 | 11 | def reset_configuration 12 | Courrier.configuration = Courrier::Configuration.new 13 | end 14 | 15 | def create_mock_provider 16 | mock_provider = Object.new 17 | 18 | def mock_provider.deliver 19 | "delivery_result" 20 | end 21 | 22 | mock_provider 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "courrier" 5 | require "minitest/autorun" 6 | 7 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { require _1 } 8 | Dir[File.expand_path("fixtures/**/*.rb", __dir__)].each { require _1 } 9 | --------------------------------------------------------------------------------