From 4230f434232918f170019b29f513701a03faf8c4 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 23 Mar 2021 18:26:52 -0400 Subject: [PATCH] Initial commit --- .editorconfig | 9 ++ .github/workflows/ci.yml | 50 ++++++++++ .gitignore | 10 ++ LICENSE | 21 +++++ README.md | 55 +++++++++++ shard.yml | 26 ++++++ spec/carbon_sendgrid_adapter_spec.cr | 123 ++++++++++++++++++++++++ spec/spec_helper.cr | 6 ++ spec/support/fake_email.cr | 21 +++++ src/carbon_sendgrid_adapter.cr | 135 +++++++++++++++++++++++++++ src/version.cr | 3 + 11 files changed, 459 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 shard.yml create mode 100644 spec/carbon_sendgrid_adapter_spec.cr create mode 100644 spec/spec_helper.cr create mode 100644 spec/support/fake_email.cr create mode 100644 src/carbon_sendgrid_adapter.cr create mode 100644 src/version.cr diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d74caa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: Carbon SendGrid Adapter CI + +on: + push: + branches: [master] + pull_request: + branches: "*" + +jobs: + check_format: + strategy: + fail-fast: false + matrix: + crystal_version: + - 0.36.1 + - 1.0.0 + experimental: + - false + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + container: crystallang/crystal:${{ matrix.crystal_version }}-alpine + steps: + - uses: actions/checkout@v1 + - name: Install shards + run: shards install + - name: Format + run: crystal tool format --check + - name: Lint + run: ./bin/ameba + specs: + strategy: + fail-fast: false + matrix: + crystal_version: + - 0.36.1 + - 1.0.0 + experimental: + - false + runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} + container: crystallang/crystal:${{ matrix.crystal_version }}-alpine + steps: + - uses: actions/checkout@v2 + - name: Cache Crystal + uses: actions/cache@v1 + with: + path: ~/.cache/crystal + key: ${{ runner.os }}-crystal + - name: Run tests + run: crystal spec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc2a7aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/docs/ +/lib/ +/bin/ +/.shards/ +*.dwarf +.env + +# Libraries don't need dependency lock +# Dependencies will be locked in applications that use them +/shard.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b986deb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 your-name-here + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c903304 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Carbon SendGrid Adapter + +Integration for Lucky's [Carbon](https://github.com/luckyframework/carbon) email library and [SendGrid](https://sendgrid.com). + +## Installation + +1. Add the dependency to your `shard.yml`: + + ```yaml + dependencies: + carbon_sendgrid_adapter: + github: luckyframework/carbon_sendgrid_adapter + ``` + +2. Run `shards install` + +## Usage + +Create an environment variable called `SEND_GRID_KEY` with your SendGrid api key. + +Update your `config/email.cr` file to use SendGrid + +```crystal +require "carbon_sendgrid_adapter" + +BaseEmail.configure do |settings| + if Lucky::Env.production? + send_grid_key = send_grid_key_from_env + settings.adapter = Carbon::SendGridAdapter.new(api_key: send_grid_key) + else + settings.adapter = Carbon::DevAdapter.enw + end +end + +private def send_grid_key_from_env + ENV["SEND_GRID_KEY"]? || raise_missing_key_message +end + +private def raise_missing_key_message + puts "Missing SEND_GRID_KEY. Set the SEND_GRID_KEY env variable to 'unused' if not sending emails, or set the SEND_GRID_KEY ENV var.".colorize.red + exit(1) +end +``` + +## Contributing + +1. Fork it () +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create a new Pull Request + +## Contributors + +- [Matthew McGarvey](https://github.com/matthewmcgarvey) - maintainer diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..7646c86 --- /dev/null +++ b/shard.yml @@ -0,0 +1,26 @@ +name: carbon_sendgrid_adapter +version: 0.1.0 + +authors: + - Matthew McGarvey + +crystal: '>= 0.36.1, < 2.0.0' + +license: MIT + +dependencies: + carbon: + github: luckyframework/carbon + version: ~> 0.1.3 + habitat: + github: luckyframework/habitat + version: ~> 0.4.4 + +development_dependencies: + dotenv: + github: gdotdesign/cr-dotenv + version: 0.7.0 + + ameba: + github: crystal-ameba/ameba + version: ~> 0.13.4 diff --git a/spec/carbon_sendgrid_adapter_spec.cr b/spec/carbon_sendgrid_adapter_spec.cr new file mode 100644 index 0000000..8e8c4ff --- /dev/null +++ b/spec/carbon_sendgrid_adapter_spec.cr @@ -0,0 +1,123 @@ +require "./spec_helper" + +describe Carbon::SendGridAdapter do + {% unless flag?("skip-integration") %} + describe "deliver_now" do + it "delivers the email successfully" do + send_email_to_send_grid text_body: "text template", + to: [Carbon::Address.new("paul@thoughtbot.com")] + end + + it "delivers emails with reply_to set" do + send_email_to_send_grid text_body: "text template", + to: [Carbon::Address.new("paul@thoughtbot.com")], + headers: {"Reply-To" => "noreply@badsupport.com"} + end + end + {% end %} + + describe "params" do + it "is not sandboxed by default" do + params_for[:mail_settings][:sandbox_mode][:enable].should be_false + end + + it "handles headers" do + headers = {"Header1" => "value1", "Header2" => "value2"} + params = params_for(headers: headers) + + params[:headers].should eq headers + end + + it "sets extracts reply-to header" do + headers = {"reply-to" => "noreply@badsupport.com", "Header" => "value"} + params = params_for(headers: headers) + + params[:headers].should eq({"Header" => "value"}) + params[:reply_to].should eq({email: "noreply@badsupport.com"}) + end + + it "sets extracts reply-to header regardless of case" do + headers = {"Reply-To" => "noreply@badsupport.com", "Header" => "value"} + params = params_for(headers: headers) + + params[:headers].should eq({"Header" => "value"}) + params[:reply_to].should eq({email: "noreply@badsupport.com"}) + end + + it "sets personalizations" do + to_without_name = Carbon::Address.new("to@example.com") + to_with_name = Carbon::Address.new("Jimmy", "to2@example.com") + cc_without_name = Carbon::Address.new("cc@example.com") + cc_with_name = Carbon::Address.new("Kim", "cc2@example.com") + bcc_without_name = Carbon::Address.new("bcc@example.com") + bcc_with_name = Carbon::Address.new("James", "bcc2@example.com") + + recipient_params = params_for( + to: [to_without_name, to_with_name], + cc: [cc_without_name, cc_with_name], + bcc: [bcc_without_name, bcc_with_name] + )[:personalizations].first + + recipient_params[:to].should eq( + [ + {name: nil, email: "to@example.com"}, + {name: "Jimmy", email: "to2@example.com"}, + ] + ) + recipient_params[:cc].should eq( + [ + {name: nil, email: "cc@example.com"}, + {name: "Kim", email: "cc2@example.com"}, + ] + ) + recipient_params[:bcc].should eq( + [ + {name: nil, email: "bcc@example.com"}, + {name: "James", email: "bcc2@example.com"}, + ] + ) + end + + it "removes empty recipients from personalizations" do + to_without_name = Carbon::Address.new("to@example.com") + + recipient_params = params_for(to: [to_without_name])[:personalizations].first + + recipient_params.keys.should eq [:to] + recipient_params[:to].should eq [{name: nil, email: "to@example.com"}] + end + + it "sets the subject" do + params_for(subject: "My subject")[:subject].should eq "My subject" + end + + it "sets the from address" do + address = Carbon::Address.new("from@example.com") + params_for(from: address)[:from].should eq({email: "from@example.com"}.to_h) + + address = Carbon::Address.new("Sally", "from@example.com") + params_for(from: address)[:from].should eq({name: "Sally", email: "from@example.com"}.to_h) + end + + it "sets the content" do + params_for(text_body: "text")[:content].should eq [{type: "text/plain", value: "text"}] + params_for(html_body: "html")[:content].should eq [{type: "text/html", value: "html"}] + params_for(text_body: "text", html_body: "html")[:content].should eq [ + {type: "text/plain", value: "text"}, + {type: "text/html", value: "html"}, + ] + end + end +end + +private def params_for(**email_attrs) + email = FakeEmail.new(**email_attrs) + Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params +end + +private def send_email_to_send_grid(**email_attrs) + api_key = ENV.fetch("SEND_GRID_API_KEY") + email = FakeEmail.new(**email_attrs) + adapter = Carbon::SendGridAdapter.new(api_key: api_key, sandbox: true) + adapter.deliver_now(email) +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..f1613bb --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,6 @@ +require "spec" +require "../src/carbon_sendgrid_adapter" +require "./support/*" +require "dotenv" + +Dotenv.load diff --git a/spec/support/fake_email.cr b/spec/support/fake_email.cr new file mode 100644 index 0000000..fab39f0 --- /dev/null +++ b/spec/support/fake_email.cr @@ -0,0 +1,21 @@ +class FakeEmail < Carbon::Email + getter text_body, html_body + + def initialize( + @from = Carbon::Address.new("from@example.com"), + @to = [] of Carbon::Address, + @cc = [] of Carbon::Address, + @bcc = [] of Carbon::Address, + @headers = {} of String => String, + @subject = "subject", + @text_body : String? = nil, + @html_body : String? = nil + ) + end + + from @from + to @to + cc @cc + bcc @bcc + subject @subject +end diff --git a/src/carbon_sendgrid_adapter.cr b/src/carbon_sendgrid_adapter.cr new file mode 100644 index 0000000..32c4e9a --- /dev/null +++ b/src/carbon_sendgrid_adapter.cr @@ -0,0 +1,135 @@ +require "http" +require "json" +require "carbon" + +class Carbon::SendgridAdapter < Carbon::Adapter + private getter api_key : String + private getter? sandbox : Bool + + def initialize(@api_key, @sandbox = false) + end + + def deliver_now(email : Carbon::Email) + Carbon::SendGridAdapter::Email.new(email, api_key, sandbox?).deliver + end + + class Email + BASE_URI = "api.sendgrid.com" + MAIL_SEND_PATH = "/v3/mail/send" + private getter email, api_key + private getter? sandbox : Bool + + def initialize(@email : Carbon::Email, @api_key : String, @sandbox = false) + end + + def deliver + client.post(MAIL_SEND_PATH, body: params.to_json).tap do |response| + unless response.success? + raise JSON.parse(response.body).inspect + end + end + end + + # :nodoc: + # Used only for testing + def params + { + personalizations: [personalizations], + subject: email.subject, + from: from, + content: content, + headers: headers, + reply_to: reply_to_params, + mail_settings: {sandbox_mode: {enable: sandbox?}}, + } + end + + private def reply_to_params + if reply_to_address + {email: reply_to_address} + end + end + + private def reply_to_address : String? + reply_to_header.values.first? + end + + private def reply_to_header + email.headers.select do |key, _value| + key.downcase == "reply-to" + end + end + + private def headers : Hash(String, String) + email.headers.reject do |key, _value| + key.downcase == "reply-to" + end + end + + private def personalizations + { + to: to_send_grid_address(email.to), + cc: to_send_grid_address(email.cc), + bcc: to_send_grid_address(email.bcc), + }.to_h.reject do |_key, value| + value.empty? + end + end + + private def to_send_grid_address(addresses : Array(Carbon::Address)) + addresses.map do |carbon_address| + { + name: carbon_address.name, + email: carbon_address.address, + } + end + end + + private def from + { + email: email.from.address, + name: email.from.name, + }.to_h.reject do |_key, value| + value.nil? + end + end + + private def content + [ + text_content, + html_content, + ].compact + end + + private def text_content + body = email.text_body + if body && !body.empty? + { + type: "text/plain", + value: body, + } + end + end + + private def html_content + body = email.html_body + if body && !body.empty? + { + type: "text/html", + value: body, + } + end + end + + @_client : HTTP::Client? + + private def client : HTTP::Client + @_client ||= HTTP::Client.new(BASE_URI, port: 443, tls: true).tap do |client| + client.before_request do |request| + request.headers["Authorization"] = "Bearer #{api_key}" + request.headers["Content-Type"] = "application/json" + end + end + end + end +end diff --git a/src/version.cr b/src/version.cr new file mode 100644 index 0000000..970c112 --- /dev/null +++ b/src/version.cr @@ -0,0 +1,3 @@ +module CarbonSendgridAdapter + VERSION = "0.1.0" +end