├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── lucky.gen.email.cr ├── script └── test ├── shard.yml ├── spec ├── address_spec.cr ├── callbacks_spec.cr ├── deliver_later_strategy_spec.cr ├── dev_adapter_spec.cr ├── email_spec.cr ├── emailable_spec.cr ├── expectations_spec.cr ├── spec_helper.cr ├── support │ ├── cleanup_helper.cr │ └── fake_email.cr ├── tasks │ └── email_spec.cr └── templates │ ├── custom_layout │ └── layout.ecr │ ├── email_with_layout │ └── html.ecr │ ├── email_with_templates │ ├── html.ecr │ └── text.ecr │ └── namespaced_email_with_templates │ ├── html.ecr │ └── text.ecr ├── src ├── carbon.cr └── carbon │ ├── adapter.cr │ ├── adapters │ └── dev_adapter.cr │ ├── address.cr │ ├── attachment.cr │ ├── callbacks.cr │ ├── deliver_later_strategy.cr │ ├── email.cr │ ├── emailable.cr │ ├── expectations.cr │ ├── expectations │ ├── be_delivered_expectation.cr │ └── have_delivered_emails_expectation.cr │ ├── spawn_strategy.cr │ ├── string_extensions.cr │ ├── tasks │ └── gen │ │ ├── email.cr │ │ └── templates │ │ ├── email.cr.ecr │ │ ├── html.ecr.ecr │ │ └── text.ecr.ecr │ └── version.cr └── tasks.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Carbon CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | check_format: 11 | strategy: 12 | fail-fast: false 13 | runs-on: ubuntu-latest 14 | continue-on-error: false 15 | steps: 16 | - name: Checkout source 17 | uses: actions/checkout@v4 18 | - name: Install Crystal 19 | uses: crystal-lang/install-crystal@v1 20 | - name: Install dependencies 21 | run: shards install 22 | - name: Format 23 | run: crystal tool format --check 24 | - name: Lint 25 | run: ./bin/ameba 26 | specs: 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | os: [ubuntu-latest, windows-latest] 31 | crystal_version: [1.10.0, latest] 32 | runs-on: ${{ matrix.os }} 33 | continue-on-error: false 34 | steps: 35 | - name: Checkout source 36 | uses: actions/checkout@v4 37 | - name: Install Crystal 38 | uses: crystal-lang/install-crystal@v1 39 | with: 40 | crystal: ${{ matrix.crystal_version }} 41 | - name: Install dependencies 42 | run: shards install --skip-postinstall --skip-executables 43 | - name: Create .env file 44 | run: touch .env 45 | - name: Run tests 46 | run: crystal spec 47 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | - uses: crystal-lang/install-crystal@v1 15 | - name: "Install shards" 16 | run: shards install 17 | - name: "Generate docs" 18 | run: crystal docs 19 | - name: Deploy to GitHub Pages 20 | uses: peaceiris/actions-gh-pages@v3 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} 23 | publish_dir: ./docs 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ameba 4 | /bin/ameba.cr 5 | /.shards/ 6 | .env 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in application that uses them 10 | /shard.lock 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Paul Smith 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 | # Carbon 2 | 3 | [![API Documentation Website](https://img.shields.io/website?down_color=red&down_message=Offline&label=API%20Documentation&up_message=Online&url=https%3A%2F%2Fluckyframework.github.io%2Fcarbon%2F)](https://luckyframework.github.io/carbon) 4 | 5 | Email library written in Crystal. 6 | 7 | ![code preview](https://user-images.githubusercontent.com/22394/38457909-9f16f9fe-3a64-11e8-852c-74e31238f48b.png) 8 | 9 | ## Installation 10 | 11 | Add this to your application's `shard.yml`: 12 | 13 | ```yaml 14 | dependencies: 15 | carbon: 16 | github: luckyframework/carbon 17 | ``` 18 | 19 | ## Adapters 20 | 21 | - `Carbon::SendGridAdapter`- See [luckyframework/carbon_sendgrid_adapter](https://github.com/luckyframework/carbon_sendgrid_adapter). 22 | - `Carbon::SmtpAdapter` - See [luckyframework/carbon_smtp_adapter](https://github.com/luckyframework/carbon_smtp_adapter). 23 | - `Carbon::AwsSesAdapter` - See [keizo3/carbon_aws_ses_adapter](https://github.com/keizo3/carbon_aws_ses_adapter). 24 | - `Carbon::SendInBlueAdapter` - See [atnos/carbon_send_in_blue_adapter](https://github.com/atnos/carbon_send_in_blue_adapter). 25 | - `Carbon::MailgunAdapter` - See [atnos/carbon_mailgun_adapter](https://github.com/atnos/carbon_mailgun_adapter). 26 | - `Carbon::SparkPostAdapter` - See [Swiss-Crystal/carbon_sparkpost_adapter](https://github.com/Swiss-Crystal/carbon_sparkpost_adapter). 27 | - `Carbon::PostmarkAdapter` - See [makisu/carbon_postmark_adapter](https://github.com/makisu/carbon_postmark_adapter). 28 | - `Carbon::MailersendAdapter` - See [balakhorvathnorbert/carbon_mailersend_adapter](https://github.com/balakhorvathnorbert/carbon_mailersend_adapter). 29 | 30 | ## Usage 31 | 32 | ### First, create a base class for your emails 33 | 34 | ```crystal 35 | require "carbon" 36 | 37 | # You can setup defaults in this class 38 | abstract class BaseEmail < Carbon::Email 39 | # For example, set up a default 'from' address 40 | from Carbon::Address.new("My App Name", "support@myapp.com") 41 | # Use a string if you just need the email address 42 | from "support@myapp.com" 43 | end 44 | ``` 45 | 46 | ### Configure the mailer class 47 | 48 | ```crystal 49 | BaseEmail.configure do |settings| 50 | settings.adapter = Carbon::DevAdapter.new(print_emails: true) 51 | end 52 | ``` 53 | 54 | ### Create a class for your email 55 | 56 | ```crystal 57 | # Create an email class 58 | class WelcomeEmail < BaseEmail 59 | def initialize(@name : String, @email_address : String) 60 | end 61 | 62 | to @email_address 63 | subject "Welcome, #{@name}!" 64 | header "My-Custom-Header", "header-value" 65 | reply_to "no-reply@noreply.com" 66 | # You can also do just `text` or `html` if you don't want both 67 | templates text, html 68 | end 69 | ``` 70 | 71 | ### Create templates 72 | 73 | Templates go in the same folder the email is in: 74 | 75 | - Text email: `/templates//text.ecr` 76 | - HTML email: `/templates//html.ecr` 77 | 78 | So if your email class is in `src/emails/welcome_email.cr`, then your 79 | templates would go in `src/emails/templates/welcome_email/text|html.ecr`. 80 | 81 | ``` 82 | # in /templates/welcome_email/text.ecr 83 | # Templates have access to instance variables and methods in the email. 84 | Welcome, <%= @name %>! 85 | ``` 86 | 87 | ``` 88 | # in /templates/welcome_email/html.ecr 89 |

Welcome, <%= @name %>!

90 | ``` 91 | 92 | For more information on what you can do with Embedded Crystal (ECR), see [the official Crystal documentation](https://crystal-lang.org/api/latest/ECR.html). 93 | 94 | ### Template layouts 95 | 96 | Layouts are optional allowing you to specify how each email template looks individually. 97 | If you'd like to have the same layout on each, you can create a layout template in 98 | `/templates//layout.ecr` 99 | 100 | In this file, you'll yield the main email body with `<%= content %>`. Then in your `BaseEmail`, you can specify the name of the layout. 101 | 102 | ```crystal 103 | abstract class BaseEmail < Carbon::Email 104 | macro inherited 105 | from default_from 106 | layout :application_layout 107 | end 108 | end 109 | ``` 110 | 111 | ``` 112 | # in src/emails/templates/application_layout/layout.ecr 113 | 114 |

Our Email

115 | 116 | <%= content %> 117 | 118 |
footer
119 | ``` 120 | 121 | ### Deliver the email 122 | 123 | ``` 124 | # Send the email right away! 125 | WelcomeEmail.new("Kate", "kate@example.com").deliver 126 | 127 | # Send the email in the background using `spawn` 128 | WelcomeEmail.new("Kate", "kate@example.com").deliver_later 129 | ``` 130 | 131 | ### Delay email delivery 132 | 133 | The built-in delay uses the `deliver_later_strategy` setting set to `Carbon::SpawnStrategy`. You can create your own custom delayed strategy 134 | that inherits from `Carbon::DeliverLaterStrategy` and defines a `run` method that takes a `Carbon::Email` and a block. 135 | 136 | One example might be a job processor: 137 | 138 | ```crystal 139 | # Define your new delayed strategy 140 | class SendEmailInJobStrategy < Carbon::DeliverLaterStrategy 141 | 142 | # `block.call` will run `deliver`, but you can call 143 | # `deliver` yourself on the `email` when you need. 144 | def run(email : Carbon::Email, &block) 145 | EmailJob.perform_later(email) 146 | end 147 | end 148 | 149 | class EmailJob < JobProcessor 150 | def perform(email : Carbon::Email) 151 | email.deliver 152 | end 153 | end 154 | 155 | # configure to use your new delayed strategy 156 | BaseEmail.configure do |settings| 157 | settings.deliver_later_strategy = SendEmailInJobStrategy.new 158 | end 159 | ``` 160 | 161 | ## Testing 162 | 163 | ### Change the adapter 164 | 165 | ```crystal 166 | # In spec/spec_helper.cr or wherever you configure your code 167 | BaseEmail.configure do 168 | # This adapter will capture all emails in memory 169 | settings.adapter = Carbon::DevAdapter.new 170 | end 171 | ``` 172 | 173 | ### Reset emails before each spec and include expectations 174 | 175 | ```crystal 176 | # In spec/spec_helper.cr 177 | 178 | # This gives you the `be_delivered` expectation 179 | include Carbon::Expectations 180 | 181 | Spec.before_each do 182 | Carbon::DevAdapter.reset 183 | end 184 | ``` 185 | 186 | ### Integration testing 187 | 188 | ```crystal 189 | # Let's say we have a class that signs the user up and sends the welcome email 190 | # that was described at the beginning of the README 191 | class SignUpUser 192 | def initialize(@name : String, @email_address : String) 193 | end 194 | 195 | def run 196 | sign_user_up 197 | WelcomeEmail.new(name: @name, email_address: @email_address).deliver 198 | end 199 | end 200 | 201 | it "sends an email after the user signs up" do 202 | SignUpUser.new(name: "Emily", email_address: "em@gmail.com").run 203 | 204 | # Test that this email was sent 205 | WelcomeEmail.new(name: "Emily", email_address: "em@gmail.com").should be_delivered 206 | end 207 | 208 | # or we can just check that some emails were sent 209 | it "sends some emails" do 210 | SignUpUser.new(name: "Emily", email_address: "em@gmail.com").run 211 | 212 | Carbon.should have_delivered_emails 213 | end 214 | ``` 215 | 216 | ### Unit testing 217 | 218 | Unit testing is simple. Instantiate your email and test the fields you care about. 219 | 220 | ```crystal 221 | it "builds a nice welcome email" do 222 | email = WelcomeEmail.new(name: "David", email_address: "david@gmail.com") 223 | # Note that recipients are converted to an array of Carbon::Address 224 | # So if you use a string value for the `to` field, you'll get an array of 225 | # Carbon::Address instead. 226 | email.to.should eq [Carbon::Address.new("david@gmail.com")] 227 | email.text_body.should contain "Welcome" 228 | email.html_body.should contain "Welcome" 229 | end 230 | ``` 231 | 232 | > Note that unit testing can be superfluous in most cases. Instead, try 233 | > unit testing just fields that have complex logic. The compiler will catch most 234 | > other issues. 235 | 236 | ## Development 237 | 238 | - `shards install` 239 | - Make changes 240 | - `./script/test` 241 | - `./bin/ameba` 242 | 243 | ## Contributing 244 | 245 | 1. Fork it ( https://github.com/luckyframework/carbon/fork ) 246 | 2. Create your feature branch (git checkout -b my-new-feature) 247 | 3. Make your changes 248 | 4. Run `./script/test` to run the specs, build shards, and check formatting 249 | 5. Commit your changes (git commit -am 'Add some feature') 250 | 6. Push to the branch (git push origin my-new-feature) 251 | 7. Create a new Pull Request 252 | 253 | ## Contributors 254 | 255 | - [paulcsmith](https://github.com/paulcsmith) Paul Smith - creator 256 | -------------------------------------------------------------------------------- /bin/lucky.gen.email.cr: -------------------------------------------------------------------------------- 1 | require "carbon" 2 | 3 | Gen::Email.new.print_help_or_call(ARGV) 4 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | printf "\nbuilding shards with 'shards build'\n\n" 4 | shards build 5 | printf "\nrunning specs with 'crystal spec'\n\n" 6 | crystal spec 7 | printf "\nformatting code with 'crystal tool format src spec'\n\n" 8 | crystal tool format src spec 9 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: carbon 2 | version: 0.6.1 3 | 4 | authors: 5 | - Paul Smith 6 | 7 | crystal: ">= 1.10.0" 8 | 9 | license: MIT 10 | 11 | executables: 12 | - lucky.gen.email.cr 13 | 14 | dependencies: 15 | habitat: 16 | github: luckyframework/habitat 17 | version: "~> 0.4.9" 18 | lucky_task: 19 | github: luckyframework/lucky_task 20 | version: "~> 0.3.0" 21 | lucky_template: 22 | github: luckyframework/lucky_template 23 | version: "~> 0.2.0" 24 | wordsmith: 25 | github: luckyframework/wordsmith 26 | version: "~> 0.5.0" 27 | 28 | development_dependencies: 29 | lucky_env: 30 | github: luckyframework/lucky_env 31 | version: ~> 0.3.0 32 | ameba: 33 | github: crystal-ameba/ameba 34 | version: ~> 1.5.0 35 | -------------------------------------------------------------------------------- /spec/address_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Carbon::Address do 4 | it "accepts just an address or a name and address" do 5 | address = Carbon::Address.new("hi@example.com") 6 | address.name.should be_nil 7 | address.address.should eq "hi@example.com" 8 | 9 | address = Carbon::Address.new("Hello", "hi@example.com") 10 | address.name.should eq "Hello" 11 | address.address.should eq "hi@example.com" 12 | end 13 | 14 | it "compares addresses based on name and address" do 15 | address = Carbon::Address.new("Hello", "hi@example.com") 16 | matching_address = Carbon::Address.new("Hello", "hi@example.com") 17 | non_matching_address = Carbon::Address.new("Bye", "hi@example.com") 18 | 19 | address.should eq matching_address 20 | address.should_not eq non_matching_address 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/callbacks_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | abstract class BaseTestEmail < Carbon::Email 4 | subject "My great subject" 5 | from Carbon::Address.new("from@example.com") 6 | to Carbon::Address.new("to@example.com") 7 | end 8 | 9 | BaseTestEmail.configure do |setting| 10 | setting.adapter = Carbon::DevAdapter.new 11 | end 12 | 13 | private class EmailWithBeforeCallbacks < BaseTestEmail 14 | property? ran_before_callback : Bool = false 15 | 16 | before_send do 17 | self.ran_before_callback = true 18 | end 19 | end 20 | 21 | private class EmailWithAfterCallbacks < BaseTestEmail 22 | property? ran_after_callback : Bool = false 23 | 24 | after_send do |_response| 25 | self.ran_after_callback = true 26 | end 27 | end 28 | 29 | private class EmailWithBothBeforeAndAfterCallbacks < BaseTestEmail 30 | property? ran_before_callback : Bool = false 31 | property? ran_after_callback : Bool = false 32 | 33 | before_send :mark_before_send 34 | after_send :mark_after_send 35 | 36 | private def mark_before_send 37 | self.ran_before_callback = true 38 | end 39 | 40 | private def mark_after_send(_response) 41 | self.ran_after_callback = true 42 | end 43 | end 44 | 45 | private class EmailUsingBeforeToStopSending < BaseTestEmail 46 | before_send :dont_actually_send 47 | after_send :never_actually_ran 48 | 49 | property? ran_after_callback : Bool = false 50 | 51 | private def dont_actually_send 52 | @deliverable = false 53 | end 54 | 55 | private def never_actually_ran(_response) 56 | self.ran_after_callback = true 57 | end 58 | end 59 | 60 | describe "before/after callbacks" do 61 | context "before an email is sent" do 62 | it "runs the before_send callback" do 63 | email = EmailWithBeforeCallbacks.new 64 | email.ran_before_callback?.should eq(false) 65 | email.deliver 66 | Carbon.should have_delivered_emails 67 | 68 | email.ran_before_callback?.should eq(true) 69 | end 70 | end 71 | 72 | context "after an email is sent" do 73 | it "runs the after_send callback" do 74 | email = EmailWithAfterCallbacks.new 75 | email.ran_after_callback?.should eq(false) 76 | email.deliver 77 | Carbon.should have_delivered_emails 78 | 79 | email.ran_after_callback?.should eq(true) 80 | end 81 | end 82 | 83 | context "running both callbacks" do 84 | it "runs both callbacks" do 85 | email = EmailWithBothBeforeAndAfterCallbacks.new 86 | email.ran_before_callback?.should eq(false) 87 | email.ran_after_callback?.should eq(false) 88 | email.deliver 89 | Carbon.should have_delivered_emails 90 | 91 | email.ran_before_callback?.should eq(true) 92 | email.ran_after_callback?.should eq(true) 93 | end 94 | end 95 | 96 | context "Halting the deliver before it's sent" do 97 | it "never sends" do 98 | email = EmailUsingBeforeToStopSending.new 99 | email.deliver 100 | Carbon.should_not have_delivered_emails 101 | email.deliverable?.should eq(false) 102 | email.ran_after_callback?.should eq(false) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/deliver_later_strategy_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class CustomDeliverLaterStrategy < Carbon::DeliverLaterStrategy 4 | property? sent : Bool = false 5 | 6 | def run(email : Carbon::Email, &block) 7 | self.sent = true 8 | block.call 9 | end 10 | end 11 | 12 | private abstract class CustomizedBaseEmail < Carbon::Email 13 | end 14 | 15 | private class CustomizedEmail < CustomizedBaseEmail 16 | subject "Test" 17 | from "test@example.com" 18 | to "to@example.com" 19 | end 20 | 21 | describe "Deliver later strategy" do 22 | it "can be customized" do 23 | strategy = CustomDeliverLaterStrategy.new 24 | use_custom_strategy(strategy) 25 | strategy.sent?.should be_false 26 | 27 | CustomizedEmail.new.deliver_later 28 | 29 | strategy.sent?.should be_true 30 | end 31 | end 32 | 33 | private def use_custom_strategy(strategy) 34 | CustomizedBaseEmail.configure do |settings| 35 | settings.adapter = Carbon::DevAdapter.new 36 | settings.deliver_later_strategy = strategy 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/dev_adapter_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Carbon::DevAdapter do 4 | it "stores emails in memory" do 5 | adapter = Carbon::DevAdapter.new 6 | Carbon::DevAdapter.delivered_emails.size.should eq 0 7 | 8 | adapter.deliver_now(FakeEmail.new(subject: "First one")) 9 | adapter.deliver_now(FakeEmail.new(subject: "Second one")) 10 | 11 | Carbon::DevAdapter.delivered_emails.size.should eq 2 12 | Carbon::DevAdapter.delivered_emails.first.subject.should eq "First one" 13 | Carbon::DevAdapter.delivered_emails[1].subject.should eq "Second one" 14 | end 15 | 16 | it "can check for delivered emails" do 17 | adapter = Carbon::DevAdapter.new 18 | email = FakeEmail.new(subject: "Sent email") 19 | unsent_email = FakeEmail.new(subject: "Unsent email") 20 | Carbon::DevAdapter.delivered?(email).should be_false 21 | 22 | adapter.deliver_now(email) 23 | 24 | Carbon::DevAdapter.delivered?(email).should be_true 25 | Carbon::DevAdapter.delivered?(unsent_email).should be_false 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/email_spec.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | require "./spec_helper" 3 | 4 | private class User 5 | include Carbon::Emailable 6 | 7 | def emailable : Carbon::Address 8 | Carbon::Address.new("user@example.com") 9 | end 10 | end 11 | 12 | private class BareMinimumEmail < Carbon::Email 13 | subject "My great subject" 14 | from Carbon::Address.new("from@example.com") 15 | to Carbon::Address.new("to@example.com") 16 | end 17 | 18 | private class EmailWithTemplates < BareMinimumEmail 19 | templates text, html 20 | end 21 | 22 | private module Namespaced 23 | class EmailWithTemplates < BareMinimumEmail 24 | templates text, html 25 | end 26 | end 27 | 28 | private class CustomizedRecipientsEmail < BareMinimumEmail 29 | cc "cc@example.com" 30 | bcc ["bcc@example.com"] 31 | end 32 | 33 | private class EmailWithEmailables < Carbon::Email 34 | from "from@example.com" 35 | to User.new 36 | subject "Doesn't matter" 37 | end 38 | 39 | private class EmailWithAttributes < BareMinimumEmail 40 | header "Custom-Header", header_value 41 | from "from@example.com" 42 | reply_to "reply_to@example.com" 43 | 44 | private def header_value 45 | "header_value" 46 | end 47 | end 48 | 49 | private class EmailWithCustomAttributes < Carbon::Email 50 | from :custom_from 51 | to :custom_to 52 | cc :custom_cc 53 | bcc :custom_bcc 54 | subject :custom_subject 55 | 56 | private def custom_from 57 | "from@example.com" 58 | end 59 | 60 | private def custom_to 61 | "to@example.com" 62 | end 63 | 64 | private def custom_cc 65 | "cc@example.com" 66 | end 67 | 68 | private def custom_bcc 69 | "bcc@example.com" 70 | end 71 | 72 | private def custom_subject 73 | "custom subject" 74 | end 75 | end 76 | 77 | private class EmailWithLayout < BareMinimumEmail 78 | templates html 79 | layout custom_layout 80 | end 81 | 82 | private class UndeliverableEmail < Carbon::Email 83 | subject "My great subject" 84 | from Carbon::Address.new("from@example.com") 85 | to Carbon::Address.new("to@example.com") 86 | end 87 | 88 | private class SerializableEmail < BareMinimumEmail 89 | include JSON::Serializable 90 | 91 | MEMORY = IO::Memory.new 92 | 93 | attachment({io: MEMORY, file_name: "file.txt", mime_type: "text/plain"}) 94 | 95 | # This is to check that an attachment cannot be added more than once 96 | attachment({file_name: "file.txt", mime_type: "text/plain", io: MEMORY}) 97 | end 98 | 99 | describe Carbon::Email do 100 | it "can build a bare minimum email" do 101 | email = BareMinimumEmail.new 102 | 103 | email.subject.should eq "My great subject" 104 | email.from.should eq Carbon::Address.new("from@example.com") 105 | email.to.should eq [Carbon::Address.new("to@example.com")] 106 | email.headers.should eq({} of String => String) 107 | end 108 | 109 | it "recipients can be customized" do 110 | email = CustomizedRecipientsEmail.new 111 | 112 | email.cc.should eq [Carbon::Address.new("cc@example.com")] 113 | email.bcc.should eq [Carbon::Address.new("bcc@example.com")] 114 | email.headers.should eq({} of String => String) 115 | end 116 | 117 | it "can use Emailables" do 118 | email = EmailWithEmailables.new 119 | 120 | email.from.should eq Carbon::Address.new("from@example.com") 121 | email.to.should eq [Carbon::Address.new("user@example.com")] 122 | end 123 | 124 | it "can render templates" do 125 | email = EmailWithTemplates.new 126 | 127 | email.text_body.should contain "text template" 128 | email.html_body.should contain "html template" 129 | end 130 | 131 | it "can render templates on a namespaced class" do 132 | email = Namespaced::EmailWithTemplates.new 133 | 134 | email.text_body.should contain "namespaced text template" 135 | email.html_body.should contain "namespaced html template" 136 | end 137 | 138 | it "can customize headers" do 139 | email = EmailWithAttributes.new 140 | email.headers["Custom-Header"].should eq "header_value" 141 | end 142 | 143 | it "has a shortcut for setting reply-to" do 144 | email = EmailWithAttributes.new 145 | email.headers["Reply-To"].should eq "reply_to@example.com" 146 | end 147 | 148 | it "can use symbols to call methods" do 149 | email = EmailWithCustomAttributes.new 150 | 151 | email.from.should eq Carbon::Address.new("from@example.com") 152 | email.to.should eq [Carbon::Address.new("to@example.com")] 153 | email.cc.should eq [Carbon::Address.new("cc@example.com")] 154 | email.bcc.should eq [Carbon::Address.new("bcc@example.com")] 155 | email.subject.should eq "custom subject" 156 | email.headers.should eq({} of String => String) 157 | end 158 | 159 | it "normalizes recipients" do 160 | end 161 | 162 | it "includes a layout" do 163 | email = EmailWithLayout.new 164 | email.html_body.should contain "Email Layout" 165 | email.html_body.should contain "Email body" 166 | end 167 | 168 | it "can be serialized" do 169 | email = SerializableEmail.from_json("{}") 170 | 171 | email.attachments.size.should eq(1) 172 | email.attachments.first[:io]?.should eq(SerializableEmail::MEMORY) 173 | end 174 | 175 | context "deliverable?" do 176 | it "is not delivery it is digiorno" do 177 | email = UndeliverableEmail.new 178 | email.deliverable = false 179 | email.deliverable?.should eq(false) 180 | end 181 | end 182 | end 183 | -------------------------------------------------------------------------------- /spec/emailable_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class User 4 | include Carbon::Emailable 5 | 6 | private def emailable : Carbon::Address 7 | Carbon::Address.new("user@example.com") 8 | end 9 | 10 | private def emailable_for_from 11 | Carbon::Address.new("User's Name", "user@example.com") 12 | end 13 | end 14 | 15 | private class UserWithoutEmailableForFrom 16 | include Carbon::Emailable 17 | 18 | private def emailable : Carbon::Address 19 | Carbon::Address.new("user@example.com") 20 | end 21 | end 22 | 23 | describe Carbon::Emailable do 24 | it "carbon_address returns the emailable" do 25 | User.new.carbon_address.should eq Carbon::Address.new("user@example.com") 26 | end 27 | 28 | it "carbon_address_for_from returns the emailable" do 29 | User.new.carbon_address_for_from.should eq Carbon::Address.new("User's Name", "user@example.com") 30 | end 31 | 32 | it "provides a default carbon_address_for_from" do 33 | UserWithoutEmailableForFrom.new.carbon_address_for_from.should eq Carbon::Address.new("user@example.com") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/expectations_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | include Carbon::Expectations 4 | 5 | describe Carbon::Expectations do 6 | describe "#be_delivered" do 7 | it "can check for delivered emails" do 8 | adapter = Carbon::DevAdapter.new 9 | email = FakeEmail.new(subject: "Sent email") 10 | other_email = FakeEmail.new(subject: "Other email") 11 | email.should_not be_delivered 12 | 13 | adapter.deliver_now(email) 14 | 15 | email.should be_delivered 16 | other_email.should_not be_delivered 17 | end 18 | end 19 | 20 | describe "#have_delivered_emails" do 21 | it "can check that emails were not sent" do 22 | Carbon.should_not have_delivered_emails 23 | end 24 | 25 | it "can check that emails were sent" do 26 | adapter = Carbon::DevAdapter.new 27 | email = FakeEmail.new(subject: "Sent email") 28 | 29 | adapter.deliver_now(email) 30 | 31 | Carbon.should have_delivered_emails 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/carbon" 3 | require "./support/**" 4 | require "lucky_env" 5 | require "lucky_template/spec" 6 | 7 | LuckyEnv.load?(".env") 8 | 9 | Spec.before_each do 10 | Carbon::DevAdapter.reset 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/cleanup_helper.cr: -------------------------------------------------------------------------------- 1 | module CleanupHelper 2 | private def cleanup 3 | FileUtils.rm_rf("./tmp") 4 | end 5 | 6 | private def with_cleanup(&) 7 | Dir.mkdir_p("./tmp") 8 | Dir.cd("./tmp") 9 | yield 10 | ensure 11 | Dir.cd("..") 12 | cleanup 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/fake_email.cr: -------------------------------------------------------------------------------- 1 | class FakeEmail < Carbon::Email 2 | getter text_body, html_body 3 | 4 | def initialize( 5 | @from = Carbon::Address.new("from@example.com"), 6 | @to = [] of Carbon::Address, 7 | @cc = [] of Carbon::Address, 8 | @bcc = [] of Carbon::Address, 9 | @headers = {} of String => String, 10 | @subject = "subject", 11 | @text_body : String? = nil, 12 | @html_body : String? = nil, 13 | ) 14 | end 15 | 16 | from @from 17 | to @to 18 | cc @cc 19 | bcc @bcc 20 | subject @subject 21 | end 22 | -------------------------------------------------------------------------------- /spec/tasks/email_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | include CleanupHelper 4 | include LuckyTemplate::Spec 5 | 6 | describe Gen::Email do 7 | it "generates a new email" do 8 | with_cleanup do 9 | generator = Gen::Email.new 10 | generator.output = IO::Memory.new 11 | generator.print_help_or_call ["PasswordReset"] 12 | generator.output.to_s.should contain("src/emails/password_reset_email.cr") 13 | generator.output.to_s.should contain("src/emails/templates/password_reset_email/html.ecr") 14 | 15 | folder = generator.email_template.template_folder 16 | folder.should be_valid_at(Path["."]) 17 | end 18 | end 19 | 20 | it "doesn't duplicate the word Email" do 21 | with_cleanup do 22 | generator = Gen::Email.new 23 | generator.output = IO::Memory.new 24 | generator.print_help_or_call ["WelcomeUserEmail"] 25 | generator.output.to_s.should contain("src/emails/welcome_user_email.cr") 26 | generator.output.to_s.should contain("src/emails/templates/welcome_user_email/html.ecr") 27 | 28 | folder = generator.email_template.template_folder 29 | folder.should be_valid_at(Path["."]) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/templates/custom_layout/layout.ecr: -------------------------------------------------------------------------------- 1 |

Email Layout

2 | 3 | <%= content %> -------------------------------------------------------------------------------- /spec/templates/email_with_layout/html.ecr: -------------------------------------------------------------------------------- 1 |

Email body

-------------------------------------------------------------------------------- /spec/templates/email_with_templates/html.ecr: -------------------------------------------------------------------------------- 1 | html template 2 | -------------------------------------------------------------------------------- /spec/templates/email_with_templates/text.ecr: -------------------------------------------------------------------------------- 1 | text template 2 | -------------------------------------------------------------------------------- /spec/templates/namespaced_email_with_templates/html.ecr: -------------------------------------------------------------------------------- 1 | namespaced html template 2 | -------------------------------------------------------------------------------- /spec/templates/namespaced_email_with_templates/text.ecr: -------------------------------------------------------------------------------- 1 | namespaced text template 2 | -------------------------------------------------------------------------------- /src/carbon.cr: -------------------------------------------------------------------------------- 1 | require "habitat" 2 | require "lucky_task" 3 | require "./carbon/string_extensions" 4 | require "./carbon/tasks/**" 5 | require "./carbon/**" 6 | 7 | module Carbon 8 | end 9 | -------------------------------------------------------------------------------- /src/carbon/adapter.cr: -------------------------------------------------------------------------------- 1 | abstract class Carbon::Adapter 2 | abstract def deliver_now(email : Carbon::Email) 3 | end 4 | -------------------------------------------------------------------------------- /src/carbon/adapters/dev_adapter.cr: -------------------------------------------------------------------------------- 1 | class Carbon::DevAdapter < Carbon::Adapter 2 | class_getter delivered_emails = [] of Carbon::Email 3 | 4 | def initialize(print_emails = false) 5 | @print_emails = print_emails 6 | end 7 | 8 | def deliver_now(email : Carbon::Email) 9 | @@delivered_emails << email 10 | if @print_emails 11 | print_email(email) 12 | end 13 | end 14 | 15 | def self.delivered?(email) : Bool 16 | delivered_emails.any?(&.== email) 17 | end 18 | 19 | def self.reset 20 | @@delivered_emails.clear 21 | end 22 | 23 | private def print_email(email : Carbon::Email) 24 | puts( 25 | "#{"To:".colorize(:green)} #{email.to}", 26 | "#{"From:".colorize(:green)} #{email.from}", 27 | "#{"Subject:".colorize(:green)} #{email.subject}", 28 | "#{"CC:".colorize(:green)} #{email.cc}", 29 | "#{"BCC:".colorize(:green)} #{email.bcc}", 30 | "#{"Headers:".colorize(:green)} #{email.headers}", 31 | "======= TEXT =======".colorize(:green), 32 | email.text_body, 33 | "======= HTML =======".colorize(:green), 34 | email.html_body 35 | ) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/carbon/address.cr: -------------------------------------------------------------------------------- 1 | require "./emailable" 2 | 3 | class Carbon::Address 4 | include Carbon::Emailable 5 | 6 | getter name, address 7 | def_equals name, address 8 | 9 | @name : String? 10 | @address : String 11 | 12 | def initialize(@address) 13 | end 14 | 15 | def initialize(@name : String, @address) 16 | end 17 | 18 | def emailable : Carbon::Address 19 | self 20 | end 21 | 22 | def to_s(io : IO) 23 | io << to_s 24 | end 25 | 26 | def to_s : String 27 | if @name 28 | "\"#{@name}\" <#{@address}>" 29 | else 30 | @address 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /src/carbon/attachment.cr: -------------------------------------------------------------------------------- 1 | module Carbon 2 | alias AttachFile = NamedTuple(file_path: String, file_name: String?, mime_type: String?) 3 | alias AttachIO = NamedTuple(io: IO, file_name: String, mime_type: String?) 4 | alias ResourceFile = NamedTuple(file_path: String, cid: String, file_name: String?, mime_type: String?) 5 | alias ResourceIO = NamedTuple(io: IO, cid: String, file_name: String, mime_type: String?) 6 | alias Attachment = AttachFile | AttachIO | ResourceFile | ResourceIO 7 | end 8 | -------------------------------------------------------------------------------- /src/carbon/callbacks.cr: -------------------------------------------------------------------------------- 1 | module Carbon::Callbacks 2 | # Runs the given method before the adapter calls `deliver_now` 3 | # 4 | # ``` 5 | # before_send :attach_metadata 6 | # 7 | # private def attach_metadata 8 | # # ... 9 | # end 10 | # ``` 11 | macro before_send(method_name) 12 | before_send do 13 | {{ method_name.id }} 14 | end 15 | end 16 | 17 | # Runs the block before the adapter calls `deliver_now` 18 | # 19 | # ``` 20 | # before_send do 21 | # # ... 22 | # end 23 | # ``` 24 | macro before_send 25 | def before_send 26 | {% if @type.methods.map(&.name).includes?(:before_send.id) %} 27 | previous_def 28 | {% else %} 29 | super 30 | {% end %} 31 | 32 | {{ yield }} 33 | end 34 | end 35 | 36 | # Runs the given method after the adapter calls `deliver_now`. 37 | # Passes in the return value of the adapter's `deliver_now` method. 38 | # 39 | # ``` 40 | # after_send :mark_email_as_sent 41 | # 42 | # private def mark_email_as_sent(response) 43 | # # ... 44 | # end 45 | # ``` 46 | macro after_send(method_name) 47 | after_send do |object| 48 | {{ method_name.id }}(object) 49 | end 50 | end 51 | 52 | # Runs the block after the adapter calls `deliver_now`, and passes the 53 | # return value of the adapter's `deliver_now` method to the block. 54 | # 55 | # ``` 56 | # after_send do |response| 57 | # # ... 58 | # end 59 | # ``` 60 | macro after_send(&block) 61 | {% 62 | if block.args.size != 1 63 | raise <<-ERR 64 | The 'after_send' callback requires exactly 1 block arg to be passed. 65 | Example: 66 | after_send { |value| some_method(value) } 67 | ERR 68 | end 69 | %} 70 | def after_send(%object) 71 | {% if @type.methods.map(&.name).includes?(:after_send.id) %} 72 | previous_def 73 | {% else %} 74 | super 75 | {% end %} 76 | 77 | {{ block.args.first }} = %object 78 | {{ block.body }} 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /src/carbon/deliver_later_strategy.cr: -------------------------------------------------------------------------------- 1 | abstract class Carbon::DeliverLaterStrategy 2 | abstract def run(email : Carbon::Email, &block) 3 | end 4 | -------------------------------------------------------------------------------- /src/carbon/email.cr: -------------------------------------------------------------------------------- 1 | require "ecr" 2 | 3 | abstract class Carbon::Email 4 | include Carbon::Callbacks 5 | alias Recipients = Carbon::Emailable | Array(Carbon::Emailable) 6 | 7 | abstract def subject : String 8 | abstract def from : Carbon::Address 9 | abstract def to : Array(Carbon::Address) 10 | 11 | def_equals subject, from, to, cc, bcc, headers, text_body, html_body, attachments 12 | 13 | # Set this value to `false` to prevent the email from 14 | # being delivered 15 | property? deliverable : Bool = true 16 | 17 | def cc 18 | [] of Carbon::Address 19 | end 20 | 21 | def bcc 22 | [] of Carbon::Address 23 | end 24 | 25 | def text_body; end 26 | 27 | def text_layout(content_io : IO); end 28 | 29 | def html_body; end 30 | 31 | def html_layout(content_io : IO); end 32 | 33 | def before_send; end 34 | 35 | def after_send(result); end 36 | 37 | getter headers 38 | 39 | macro inherited 40 | macro templates(*content_types) 41 | \{% for content_type in content_types %} 42 | def \{{ content_type }}_body : String 43 | content_io = IO::Memory.new 44 | ECR.embed "#{__DIR__}/templates/\{{ @type.name.underscore.gsub(/::/, "_") }}/\{{ content_type }}.ecr", content_io 45 | \{{ content_type }}_layout(content_io) || content_io.to_s 46 | end 47 | \{% end %} 48 | end 49 | 50 | # Specify an HTML template layout 51 | # 52 | # ``` 53 | # templates html 54 | # layout email_layout 55 | # ``` 56 | macro layout(template_name) 57 | def html_layout(content_io : IO) 58 | content = content_io.to_s 59 | layout_io = IO::Memory.new 60 | ECR.embed "#{__DIR__}/templates/\{{ template_name.id }}/layout.ecr", layout_io 61 | layout_io.to_s 62 | end 63 | end 64 | end 65 | 66 | @headers = {} of String => String 67 | 68 | macro reply_to(address) 69 | header "Reply-To", {{ address }} 70 | end 71 | 72 | macro header(key, value) 73 | def headers : Hash(String, String) 74 | {% if @type.methods.map(&.name).includes?(:headers.id) %} 75 | previous_def 76 | {% end %} 77 | @headers[{{ key }}] = {{ value }} 78 | @headers 79 | end 80 | end 81 | 82 | macro from(value) 83 | def from : Carbon::Address 84 | normalize(id_or_method({{ value }})).first 85 | end 86 | end 87 | 88 | macro subject(value) 89 | def subject : String 90 | id_or_method({{ value }}) 91 | end 92 | end 93 | 94 | def attachments 95 | [] of Carbon::Attachment 96 | end 97 | 98 | macro attachment(value) 99 | def attachments : Array(Carbon::Attachment) 100 | {% if @type.methods.map(&.name).includes?(:attachments.id) %} 101 | previous_def | [{{ value }}] 102 | {% else %} 103 | super | [{{ value }}] 104 | {% end %} 105 | end 106 | end 107 | 108 | {% for method in [:to, :cc, :bcc] %} 109 | macro {{ method.id }}(value) 110 | def {{ method.id }} : Array(Carbon::Address) 111 | normalize(id_or_method(\{{ value }})) 112 | end 113 | end 114 | {% end %} 115 | 116 | macro id_or_method(value) 117 | {% if value.is_a?(SymbolLiteral) %} 118 | {{ value.id }} 119 | {% else %} 120 | {{ value }} 121 | {% end %} 122 | end 123 | 124 | private def normalize(recipients : Carbon::Email::Recipients) : Array(Carbon::Address) 125 | recipients = if recipients.is_a?(Array) 126 | recipients 127 | else 128 | [recipients] 129 | end 130 | 131 | recipients.map(&.carbon_address) 132 | end 133 | 134 | macro inherited 135 | {% if @type.abstract? %} 136 | Habitat.create do 137 | setting adapter : Carbon::Adapter 138 | setting deliver_later_strategy : Carbon::DeliverLaterStrategy = Carbon::SpawnStrategy.new 139 | end 140 | {% end %} 141 | end 142 | 143 | macro configure 144 | {% raise "Make #{@type.name} abstract in order to configure it." %} 145 | end 146 | 147 | def deliver 148 | before_send 149 | 150 | if deliverable? 151 | response = settings.adapter.deliver_now(self) 152 | after_send(response) 153 | end 154 | end 155 | 156 | def deliver_later 157 | settings.deliver_later_strategy.run(self) do 158 | deliver 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /src/carbon/emailable.cr: -------------------------------------------------------------------------------- 1 | module Carbon::Emailable 2 | private abstract def emailable : Carbon::Address | String 3 | 4 | # Adapter's should use this to get the Carbon::Address 5 | def carbon_address : Carbon::Address 6 | ensure_carbon_address(emailable) 7 | end 8 | 9 | # Adapter's should use this to get the Carbon::Address when used for 'from' 10 | def carbon_address_for_from : Carbon::Address 11 | ensure_carbon_address(emailable_for_from) 12 | end 13 | 14 | private def emailable_for_from 15 | emailable 16 | end 17 | 18 | private def ensure_carbon_address(value : Carbon::Address) : Carbon::Address 19 | value 20 | end 21 | 22 | private def ensure_carbon_address(value : String) 23 | {% 24 | raise <<-ERROR 25 | 26 | #{@type}#emailable returned String, but it must return a Carbon::Address 27 | 28 | Try this... 29 | 30 | ▸ Carbon::Address.new("person@gmail.com") 31 | ERROR 32 | %} 33 | end 34 | 35 | private def ensure_carbon_address(value : T) forall T 36 | {% 37 | raise <<-ERROR 38 | 39 | #{@type}#emailable returned #{T}, but it must return a Carbon::Address 40 | 41 | Try this... 42 | 43 | ▸ Carbon::Address.new(name: "Name", address: "person@gmail.com") 44 | ERROR 45 | %} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /src/carbon/expectations.cr: -------------------------------------------------------------------------------- 1 | require "./expectations/*" 2 | 3 | module Carbon::Expectations 4 | private def be_delivered 5 | BeDeliveredExpectation.new 6 | end 7 | 8 | private def have_delivered_emails 9 | HaveDeliveredEmailsExpectation.new 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/carbon/expectations/be_delivered_expectation.cr: -------------------------------------------------------------------------------- 1 | struct Carbon::Expectations::BeDeliveredExpectation 2 | def match(email : Carbon::Email) : Bool 3 | Carbon::DevAdapter.delivered?(email) 4 | end 5 | 6 | def failure_message(email) 7 | String.build do |message| 8 | message << "Expected: #{email} to be delivered" 9 | if Carbon::DevAdapter.delivered_emails.empty? 10 | message << ", but no emails were delivered" 11 | else 12 | message << "\n\nTry this..." 13 | message << "\n\n ▸ See what emails were delivered with 'p Carbon::DevAdapter.delivered_emails'" 14 | end 15 | end 16 | end 17 | 18 | def negative_failure_message(email) 19 | "Expected: #{email} not to be delivered" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/carbon/expectations/have_delivered_emails_expectation.cr: -------------------------------------------------------------------------------- 1 | class Carbon::Expectations::HaveDeliveredEmailsExpectation 2 | def match(_carbon : Carbon.class) : Bool 3 | !Carbon::DevAdapter.delivered_emails.empty? 4 | end 5 | 6 | def failure_message(_carbon : Carbon.class) 7 | "Expected: Carbon to have delivered emails, but found none" 8 | end 9 | 10 | def negative_failure_message(_carbon : Carbon.class) 11 | "Expected: Carbon to have no delivered emails, but found some" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/carbon/spawn_strategy.cr: -------------------------------------------------------------------------------- 1 | class Carbon::SpawnStrategy < Carbon::DeliverLaterStrategy 2 | def run(email, &block) 3 | spawn do 4 | block.call 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /src/carbon/string_extensions.cr: -------------------------------------------------------------------------------- 1 | require "./emailable" 2 | 3 | class String 4 | include Carbon::Emailable 5 | 6 | def emailable : Carbon::Address 7 | Carbon::Address.new(address: self) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/carbon/tasks/gen/email.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | require "lucky_template" 3 | require "wordsmith" 4 | require "file_utils" 5 | 6 | class Carbon::EmailTemplate 7 | def initialize(@email_filename : String, @email_class_name : String) 8 | end 9 | 10 | def render(path : Path) 11 | LuckyTemplate.write!(path, template_folder) 12 | end 13 | 14 | def template_folder 15 | LuckyTemplate.create_folder do |top_dir| 16 | top_dir.add_folder(Path["src/emails"]) do |email_dir| 17 | email_dir.add_file("#{@email_filename}_email.cr") do |io| 18 | ECR.embed("#{__DIR__}/templates/email.cr.ecr", io) 19 | end 20 | email_dir.add_folder(Path["templates"]) do |templates_dir| 21 | templates_dir.add_folder("#{@email_filename}_email") do |email_templates_dir| 22 | email_templates_dir.add_file("html.ecr") do |io| 23 | ECR.embed("#{__DIR__}/templates/html.ecr.ecr", io) 24 | end 25 | email_templates_dir.add_file("text.ecr") do |io| 26 | ECR.embed("#{__DIR__}/templates/text.ecr.ecr", io) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | 35 | class Gen::Email < LuckyTask::Task 36 | include LuckyTask::TextHelpers 37 | 38 | summary "Generate a new Email" 39 | 40 | {% begin %} 41 | {% lt_0_3_0 = compare_versions(LuckyTask::VERSION, "0.3.0") < 0 %} 42 | {% if lt_0_3_0 %}def help_message{% else %}help_message({% end %} 43 | <<-TEXT 44 | Generate a new email with html and text formats. 45 | The email name must be CamelCase. No other options are available. 46 | Examples: 47 | lucky gen.email WelcomeUser 48 | lucky gen.email SubscriptionRenewed 49 | lucky gen.email ResetPassword 50 | TEXT 51 | {% if lt_0_3_0 %}end{% else %}){% end %} 52 | {% end %} 53 | 54 | positional_arg :email_name, "The name of the email", format: /^[A-Z]/ 55 | 56 | def call 57 | email_template.render(Path["."]) 58 | 59 | display_success_messages 60 | end 61 | 62 | def email_template 63 | Carbon::EmailTemplate.new(filename, normalized_email_name) 64 | end 65 | 66 | private def normalized_email_name : String 67 | email_name.gsub(/email$/i, "") 68 | end 69 | 70 | private def filename : String 71 | Wordsmith::Inflector.underscore(normalized_email_name) 72 | end 73 | 74 | private def display_success_messages 75 | snapshot = LuckyTemplate.snapshot(email_template.template_folder) 76 | paths = snapshot.reject { |_, v| v.folder? }.keys 77 | 78 | output.puts "Generated email template" 79 | output.puts 80 | paths.each_with_index do |path, index| 81 | output.print " #{green_arrow} #{path.colorize.bold}" 82 | unless index == paths.size - 1 83 | output.print '\n' 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/carbon/tasks/gen/templates/email.cr.ecr: -------------------------------------------------------------------------------- 1 | class <%= @email_class_name %>Email < BaseEmail 2 | # Read more on emails 3 | # https://luckyframework.org/guides/emails/sending-emails-with-carbon 4 | # 5 | # Send this email with: 6 | # ``` 7 | # recipient = UserQuery.first 8 | # <%= @email_class_name %>Email.new(recipient).deliver 9 | # ``` 10 | 11 | def initialize(@recipient : Carbon::Emailable) 12 | end 13 | 14 | to @recipient 15 | from "myapp@support.com" # or set a default in src/emails/base_email.cr 16 | subject :email_subject 17 | templates html, text 18 | 19 | private def email_subject : String 20 | {% 21 | raise <<-MESSAGE 22 | No subject has been defined for <%= @email_class_name %>Email. 23 | 24 | Be sure to replace the `:email_subject` with your subject 25 | MESSAGE 26 | %} 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /src/carbon/tasks/gen/templates/html.ecr.ecr: -------------------------------------------------------------------------------- 1 |

Your email HTML body goes here.

2 |

Update this in <%= "src/emails/templates/#{@email_filename}_email/html.ecr" %>.

3 | -------------------------------------------------------------------------------- /src/carbon/tasks/gen/templates/text.ecr.ecr: -------------------------------------------------------------------------------- 1 | Your email TEXT body goes here. 2 | 3 | Update this in <%= "src/emails/templates/#{@email_filename}_email/text.ecr" %>. 4 | -------------------------------------------------------------------------------- /src/carbon/version.cr: -------------------------------------------------------------------------------- 1 | module Carbon 2 | VERSION = "0.6.1" 3 | end 4 | -------------------------------------------------------------------------------- /tasks.cr: -------------------------------------------------------------------------------- 1 | require "lucky_task" 2 | require "./src/carbon" 3 | 4 | Habitat.raise_if_missing_settings! 5 | LuckyTask::Runner.run 6 | --------------------------------------------------------------------------------