Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 104 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Courrier

API-powered email delivery for Ruby apps.
API-powered email delivery and newsletter subscription management for Ruby apps

![A cute cartoon mascot wearing a blue postal uniform with red scarf and cap, carrying a leather messenger bag, representing an API-powered email delivery gem for Ruby apps](https://raw.githubusercontent.com/Rails-Designer/courrier/HEAD/.github/cover.jpg)

Expand All @@ -15,6 +15,9 @@ class OrderEmail < Courrier::Email
end

OrderEmail.deliver to: "recipient@railsdesigner.com"

# Manage newsletter subscriptions
Courrier::Subscriber.create "subscriber@example.com"
```

<a href="https://railsdesigner.com/" target="_blank">
Expand Down Expand Up @@ -80,8 +83,11 @@ Courrier uses a configuration system with three levels (from lowest to highest p
1. **Global configuration**
```ruby
Courrier.configure do |config|
config.provider = "postmark"
config.api_key = "xyz"
config.email = {
provider: "postmark",
api_key: "xyz"
}

config.from = "devs@railsdesigner.com"
config.default_url_options = { host: "railsdesigner.com" }

Expand Down Expand Up @@ -112,7 +118,7 @@ OrderEmail.deliver to: "recipient@railsdesigner.com",\
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.


## Custom Attributes
## Custom attributes

Besides the standard email attributes (`from`, `to`, `reply_to`, etc.), you can pass any additional attributes that will be available in your email templates:
```ruby
Expand All @@ -130,12 +136,12 @@ end
```


## Result Object
## Result object

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.


### Available Methods
### Available methods

| Method | Return Type | Description |
|:-------|:-----------|:------------|
Expand Down Expand Up @@ -176,7 +182,7 @@ Courrier supports these transactional email providers:
Additional functionality to help with development and testing:


### Background Jobs (Rails only)
### Background jobs (Rails only)

Use `deliver_later` to enqueue delivering using Rails' ActiveJob. You can set
various ActiveJob-supported options in the email class, like so: `enqueue queue: "emails", wait: 5.minutes`.
Expand Down Expand Up @@ -206,7 +212,7 @@ config.inbox.auto_open = true
Emails are automatically cleared with `bin/rails tmp:clear`, or manually with `bin/rails courrier:clear`.


### Layout Support
### Layout support

Wrap your email content using layouts:
```ruby
Expand Down Expand Up @@ -249,15 +255,15 @@ end
```


### Auto-generate Text from HTML
### Auto-generate text from HTML

Automatically generate plain text versions from your HTML emails:
```ruby
config.auto_generate_text = true # Defaults to false
```


### Email Address Helper
### Email address helper

Compose email addresses with display names:
```ruby
Expand All @@ -284,16 +290,17 @@ end
```


### Logger Provider
### Logger provider

Use Ruby's built-in Logger for development and testing:

```ruby
config.provider = "logger" # Outputs emails to STDOUT
config.logger = custom_logger # Optional: defaults to ::Logger.new($stdout)
config.provider = "logger" # outputs emails to STDOUT
config.logger = custom_logger # optional: defaults to ::Logger.new($stdout)
```

### Custom Providers

### Custom providers

Create your own provider by inheriting from `Courrier::Email::Providers::Base`:
```ruby
Expand All @@ -314,6 +321,89 @@ config.provider = "CustomProvider"
Check the [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/email/providers) for implementation examples.


## Newsletter subscriptions

Manage subscribers across popular email marketing platforms:
```ruby
Courrier.configure do |config|
config.subscriber = {
provider: "buttondown",
api_key: "your_api_key"
}
end
```

```ruby
# Add a subscriber
subscriber = Courrier::Subscriber.create "subscriber@example.com"

# Remove a subscriber
subscriber = Courrier::Subscriber.destroy "subscriber@example.com"

if subscriber.success?
puts "Subscriber added!"
else
puts "Error: #{subscriber.error}"
end
```


### Supported providers

- [Beehiiv](https://www.beehiiv.com/) - requires `publication_id`
- [Buttondown](https://buttondown.com)
- [Kit](https://kit.com/) (formerly ConvertKit) - requires `form_id`
- [Loops](https://loops.so/)
- [Mailchimp](https://mailchimp.com/) - requires `dc` and `list_id`
- [MailerLite](https://www.mailerlite.com/)

Provider-specific configuration:
```ruby
config.subscriber = {
provider: "mailchimp",
api_key: "your_api_key",
dc: "us19",
list_id: "abc123"
}
```

### Custom providers

Create custom providers by inheriting from `Courrier::Subscriber::Base`:
```ruby
class CustomSubscriberProvider < Courrier::Subscriber::Base
ENDPOINT_URL = "https://api.example.com/subscribers"

def create(email)
request(:post, ENDPOINT_URL, {"email" => email})
end

def destroy(email)
request(:delete, "#{ENDPOINT_URL}/#{email}")
end

private

def headers
{
"Authorization" => "Bearer #{@api_key}",
"Content-Type" => "application/json"
}
end
end
```

Then configure it:
```ruby
config.subscriber = {
provider: CustomSubscriberProvider,
api_key: "your_api_key"
}
```

See [existing providers](https://github.com/Rails-Designer/courrier/tree/main/lib/courrier/subscriber) for more examples.


## FAQ

### Is this a replacement for ActionMailer?
Expand Down
1 change: 1 addition & 0 deletions lib/courrier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "courrier/errors"
require "courrier/configuration"
require "courrier/email"
require "courrier/subscriber"
require "courrier/engine" if defined?(Rails)
require "courrier/railtie" if defined?(Rails)

Expand Down
32 changes: 29 additions & 3 deletions lib/courrier/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ def configuration
end

class Configuration
attr_accessor :provider, :api_key, :logger, :email_path, :layouts, :default_url_options, :auto_generate_text,
attr_accessor :email, :subscriber, :logger, :email_path, :layouts, :default_url_options, :auto_generate_text,
:from, :reply_to, :cc, :bcc

attr_reader :providers, :inbox

def initialize
@provider = "logger"
@api_key = nil
@email = {provider: "logger"}
@subscriber = {}

@logger = ::Logger.new($stdout)
@email_path = default_email_path

Expand All @@ -42,6 +44,30 @@ def initialize
@inbox = Courrier::Configuration::Inbox.new
end

def provider
warn "[DEPRECATION] `provider` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"

@email[:provider]
end

def provider=(value)
warn "[DEPRECATION] `provider=` is deprecated. Use `email = { provider: '…' }` instead. Will be removed in 1.0.0"

@email[:provider] = value
end

def api_key
warn "[DEPRECATION] `api_key` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"

@email[:api_key]
end

def api_key=(value)
warn "[DEPRECATION] `api_key=` is deprecated. Use `email = { api_key: '…' }` instead. Will be removed in 1.0.0"

@email[:api_key] = value
end

private

def default_email_path
Expand Down
6 changes: 3 additions & 3 deletions lib/courrier/email.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class << self
define_method(attribute) do
instance_variable_get("@#{attribute}") ||
(superclass.respond_to?(attribute) ? superclass.send(attribute) : nil) ||
Courrier.configuration&.send(attribute)
(["provider", "api_key"].include?(attribute) ? Courrier.configuration&.email&.[](attribute.to_sym) : Courrier.configuration&.send(attribute))
end

define_method("#{attribute}=") do |value|
Expand Down Expand Up @@ -66,8 +66,8 @@ def inherited(subclass)
end

def initialize(options = {})
@provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.provider
@api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.api_key
@provider = options[:provider] || ENV["COURRIER_PROVIDER"] || self.class.provider || Courrier.configuration&.email&.[](:provider)
@api_key = options[:api_key] || ENV["COURRIER_API_KEY"] || self.class.api_key || Courrier.configuration&.email&.[](:api_key)

@default_url_options = self.class.default_url_options.merge(options[:default_url_options] || {})
@context_options = options.except(:provider, :api_key, :from, :to, :reply_to, :cc, :bcc, :subject, :text, :html)
Expand Down
3 changes: 2 additions & 1 deletion lib/courrier/email/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ class Provider
def initialize(provider: nil, api_key: nil, options: {}, provider_options: {}, context_options: {})
@provider = provider
@api_key = api_key

@options = options
@provider_options = provider_options
@context_options = context_options
end

def deliver
raise Courrier::ConfigurationError, "`provider` and `api_key` must be configured for production environment" if configuration_missing_in_production?
raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.nil? || @provider.empty?
raise Courrier::ConfigurationError, "Unknown provider. Choose one of `#{comma_separated_providers}` or provide your own." if @provider.nil? || @provider.to_s.strip.empty?

provider_class.new(
api_key: @api_key,
Expand Down
34 changes: 34 additions & 0 deletions lib/courrier/subscriber.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module Courrier
class Subscriber
class << self
def create(email)
provider.create(email)
end
alias_method :add, :create

def destroy(email)
provider.destroy(email)
end
alias_method :delete, :destroy

private

def provider
@provider ||= provider_class.new(
api_key: Courrier.configuration.subscriber[:api_key]
)
end

def provider_class
provider_name = Courrier.configuration.subscriber[:provider]

return provider_name if provider_name.is_a?(Class)
require "courrier/subscriber/#{provider_name}"

Object.const_get("Courrier::Subscriber::#{provider_name.capitalize}")
end
end
end
end
51 changes: 51 additions & 0 deletions lib/courrier/subscriber/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "courrier/subscriber/result"

module Courrier
class Subscriber
class Base
def initialize(api_key:)
@api_key = api_key
end

def create(email)
raise NotImplementedError
end

def destroy(email)
raise NotImplementedError
end

private

def request(method, url, body = nil)
uri = URI(url)
request_class = case method
when :post then Net::HTTP::Post
when :delete then Net::HTTP::Delete
when :put then Net::HTTP::Put
when :patch then Net::HTTP::Patch
when :get then Net::HTTP::Get
end

request = request_class.new(uri)
request.body = body.to_json if body

headers.each { |key, value| request[key] = value }

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end

Courrier::Subscriber::Result.new(response: response)
rescue => error
Courrier::Subscriber::Result.new(error: error)
end

def headers
raise NotImplementedError
end
end
end
end
Loading