├── .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 | 
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 |
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 |
109 | <%= @options.subject %>
110 |
111 |
112 |
113 |
>
114 |
Toggle HTML/Text
115 |
116 |
117 |
<%= text || "(no text content)" %>
118 |
119 |
120 |
121 | <%= html || "
(no HTML content)
" %>
122 |
123 |
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 | Log in to your account
32 | Complete your profile
33 | Explore our features
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:\nTest 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 |
--------------------------------------------------------------------------------