diff --git a/README.md b/README.md index 5af695d..98ed3c4 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Courrier.configure do |config| config.providers.cloudflare.account_id = "your-account-id" config.providers.loops.transactional_id = "default-template" config.providers.mailgun.domain = "notifications.railsdesigner.com" + config.providers.smtpcom.channel = "your-sender-channel" config.providers.ses.region = "us-east-1" config.providers.ses.access_key_id = "your-access-key-id" @@ -202,6 +203,7 @@ Courrier supports these transactional email providers: - [Resend](https://resend.com) - [SendGrid](https://sendgrid.com) - [SMTP2GO](https://www.smtp2go.com/) +- [SMTP.com](https://www.smtp.com/) — requires a `channel` - [SparkPost](https://www.sparkpost.com/) - [Userlist](https://userlist.com) diff --git a/lib/courrier/email/provider.rb b/lib/courrier/email/provider.rb index 7de7e4d..dde4ba8 100644 --- a/lib/courrier/email/provider.rb +++ b/lib/courrier/email/provider.rb @@ -14,6 +14,7 @@ require "courrier/email/providers/ses" require "courrier/email/providers/sendgrid" require "courrier/email/providers/smtp2go" +require "courrier/email/providers/smtpcom" require "courrier/email/providers/sparkpost" require "courrier/email/providers/userlist" @@ -34,6 +35,7 @@ class Provider ses: Courrier::Email::Providers::Ses, sendgrid: Courrier::Email::Providers::Sendgrid, smtp2go: Courrier::Email::Providers::Smtp2go, + smtpcom: Courrier::Email::Providers::Smtpcom, sparkpost: Courrier::Email::Providers::Sparkpost, userlist: Courrier::Email::Providers::Userlist } diff --git a/lib/courrier/email/providers/smtpcom.rb b/lib/courrier/email/providers/smtpcom.rb new file mode 100644 index 0000000..36f9239 --- /dev/null +++ b/lib/courrier/email/providers/smtpcom.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Courrier + class Email + module Providers + class Smtpcom < Base + def self.config_options = %w[channel] + + ENDPOINT_URL = "https://api.smtp.com/v4/messages" + + def body + { + "channel" => channel, + "recipients" => { + "to" => email_addresses(@options.to), + "cc" => email_addresses(@options.cc), + "bcc" => email_addresses(@options.bcc) + }.compact, + "originator" => { + "from" => email_address(@options.from), + "reply_to" => email_address(@options.reply_to) + }.compact, + "subject" => @options.subject, + "body" => {"parts" => parts} + } + end + + private + + def default_headers + {"Authorization" => "Basic #{base64("api:#{@api_key}")}"} + end + + def channel + @provider_options.channel || raise(Courrier::ArgumentError, "SMTP.com requires a `channel`") + end + + def parts + [part("text/plain", @options.text), part("text/html", @options.html)].compact + end + + def part(type, content) + return unless content + + { + "type" => type, + "charset" => "UTF-8", + "encoding" => "base64", + "content" => base64(content) + } + end + + def base64(value) = [value].pack("m0") + + def email_addresses(addresses) + addresses&.split(",")&.map { |address| email_address(address) } + end + + def email_address(address) + {"address" => address.strip} if address + end + end + end + end +end diff --git a/test/courrier/email/providers/smtpcom_test.rb b/test/courrier/email/providers/smtpcom_test.rb new file mode 100644 index 0000000..3c8998b --- /dev/null +++ b/test/courrier/email/providers/smtpcom_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +module Courrier::Email::Providers + class SmtpcomTest < Minitest::Test + def setup + email = TestEmail.new( + from: "devs@railsdesigner.com", + to: "first@example.com, second@example.com", + reply_to: "support@railsdesigner.com", + cc: "copy@example.com", + bcc: "archive@example.com" + ) + + @provider = provider_for(email) + end + + def test_formats_transactional_email + assert_equal( + { + "channel" => "test_channel", + "recipients" => { + "to" => [{"address" => "first@example.com"}, {"address" => "second@example.com"}], + "cc" => [{"address" => "copy@example.com"}], + "bcc" => [{"address" => "archive@example.com"}] + }, + "originator" => { + "from" => {"address" => "devs@railsdesigner.com"}, + "reply_to" => {"address" => "support@railsdesigner.com"} + }, + "subject" => "Test Subject", + "body" => { + "parts" => [ + {"type" => "text/plain", "charset" => "UTF-8", "encoding" => "base64", "content" => ["Test Body"].pack("m0")}, + {"type" => "text/html", "charset" => "UTF-8", "encoding" => "base64", "content" => ["

Test HTML Body

"].pack("m0")} + ] + } + }, + @provider.body + ) + end + + def test_omits_optional_addresses_when_not_set + body = provider_for(TestEmail.new(from: "devs@railsdesigner.com", to: "first@example.com")).body + + refute_includes body["recipients"].keys, "cc" + refute_includes body["recipients"].keys, "bcc" + refute_includes body["originator"].keys, "reply_to" + end + + def test_requires_a_channel + provider = Smtpcom.new( + api_key: "test_key", + options: TestEmail.new(from: "devs@railsdesigner.com", to: "first@example.com").options, + provider_options: Courrier::Configuration::ProviderConfig.new + ) + + error = assert_raises(Courrier::ArgumentError) { provider.body } + + assert_match(/channel/, error.message) + end + + def test_authenticates_with_api_key + assert_equal( + {"Authorization" => "Basic #{["api:test_key"].pack("m0")}"}, + @provider.send(:default_headers) + ) + end + + def test_is_available_through_provider_registry + mock_provider = Minitest::Mock.new + mock_provider.expect(:deliver, nil) + + Smtpcom.stub :new, mock_provider do + Courrier::Email::Provider.new( + provider: "smtpcom", + api_key: "test_key", + options: @provider.instance_variable_get(:@options) + ).deliver + end + + mock_provider.verify + end + + private + + def provider_for(email) + provider_options = Courrier::Configuration::ProviderConfig.new + provider_options.channel = "test_channel" + + Smtpcom.new(api_key: "test_key", options: email.options, provider_options: provider_options) + end + end +end