From 48156ecad5867c3c71610333f4e43cd4f026ee81 Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Thu, 15 Jan 2026 01:12:51 -0700 Subject: [PATCH 1/3] feat: add categories and send_at support for SendGrid API Wire categories and send_at extension methods to the SendGrid API payload. These fields allow organizing emails by category for analytics and scheduling emails to be sent at a specific time. - Add extra_params method to handle flexible-typed fields - Merge extra_params into JSON body during deliver - Add tests verifying categories and send_at are sent to API - Add EmailWithSendGridFeatures test fixture --- spec/carbon_sendgrid_adapter_spec.cr | 31 ++++++++++++++++ spec/support/email_with_sendgrid_features.cr | 39 ++++++++++++++++++++ src/carbon_sendgrid_adapter.cr | 30 ++++++++++++++- src/carbon_sendgrid_extensions.cr | 14 +++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 spec/support/email_with_sendgrid_features.cr diff --git a/spec/carbon_sendgrid_adapter_spec.cr b/spec/carbon_sendgrid_adapter_spec.cr index 6ea5d18..bb8b318 100644 --- a/spec/carbon_sendgrid_adapter_spec.cr +++ b/spec/carbon_sendgrid_adapter_spec.cr @@ -1,4 +1,5 @@ require "./spec_helper" +require "./support/email_with_sendgrid_features" describe Carbon::SendGridAdapter do {% if flag?("with-integration") %} @@ -163,6 +164,36 @@ describe Carbon::SendGridAdapter do attachments.first["filename"].should eq("contract.pdf") Base64.decode_string(attachments.first["content"].to_s).should eq("Sign here") end + + describe "sendgrid specific features" do + it "includes categories in extra_params when defined" do + email = EmailWithSendGridFeatures.new(text_body: "0") + extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + + extra["categories"].should eq(["welcome", "onboarding", "transactional"]) + end + + it "does not include categories when not defined" do + email = FakeEmail.new(text_body: "0") + extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + + extra.has_key?("categories").should eq(false) + end + + it "includes send_at in extra_params when defined" do + email = EmailWithSendGridFeatures.new(text_body: "0") + extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + + extra["send_at"].should eq(1704067200_i64) + end + + it "does not include send_at when not defined" do + email = FakeEmail.new(text_body: "0") + extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + + extra.has_key?("send_at").should eq(false) + end + end end end diff --git a/spec/support/email_with_sendgrid_features.cr b/spec/support/email_with_sendgrid_features.cr new file mode 100644 index 0000000..8ac6448 --- /dev/null +++ b/spec/support/email_with_sendgrid_features.cr @@ -0,0 +1,39 @@ +class EmailWithSendGridFeatures < 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 + + def template_id + "d-1234567890" + end + + def dynamic_template_data + { + "name" => "Test User", + } + end + + def categories : Array(String) + ["welcome", "onboarding", "transactional"] + end + + def send_at : Int64 + 1704067200_i64 # 2024-01-01 00:00:00 UTC + 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 index 5196280..a7f0697 100644 --- a/src/carbon_sendgrid_adapter.cr +++ b/src/carbon_sendgrid_adapter.cr @@ -27,7 +27,18 @@ class Carbon::SendGridAdapter < Carbon::Adapter end def deliver - body = params.to_json + # Merge params with extra_params for JSON serialization + json_data = JSON.parse(params.to_json).as_h + extra_params.each do |key, value| + case value + when Array(String) + json_data[key] = JSON::Any.new(value.map { |v| JSON::Any.new(v) }) + when Int64 + json_data[key] = JSON::Any.new(value) + end + end + body = json_data.to_json + client.post(MAIL_SEND_PATH, body: body).tap do |response| unless response.success? raise SendGridResponseFailedError.new(response.body) @@ -83,6 +94,23 @@ class Carbon::SendGridAdapter < Carbon::Adapter data end + # :nodoc: + # Additional parameters that need flexible typing (categories, send_at) + # These are merged into the JSON body separately in deliver + def extra_params + extras = {} of String => Array(String) | Int64 + + if categories = email.categories + extras["categories"] = categories + end + + if send_at = email.send_at + extras["send_at"] = send_at + end + + extras + end + private def reply_to_params : Hash(String, String)? if reply = reply_to_address {"email" => reply} diff --git a/src/carbon_sendgrid_extensions.cr b/src/carbon_sendgrid_extensions.cr index 0af070e..370efa7 100644 --- a/src/carbon_sendgrid_extensions.cr +++ b/src/carbon_sendgrid_extensions.cr @@ -24,6 +24,20 @@ module Carbon::SendGridExtensions def asm nil end + + # Define categories for your email to organize + # and track analytics by category. + # https://docs.sendgrid.com/ui/analytics-and-reporting/categories + def categories : Array(String)? + nil + end + + # Define a unix timestamp to schedule when + # the email should be sent. + # https://docs.sendgrid.com/ui/sending-email/scheduling-parameters + def send_at : Int64? + nil + end end class Carbon::Email From 5d17289c68952a732aff46c93a3092f3ced5e164 Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Thu, 15 Jan 2026 01:12:57 -0700 Subject: [PATCH 2/3] style: add trailing commas in test fixture initializers --- spec/support/custom_template_email.cr | 2 +- spec/support/fake_email.cr | 2 +- spec/support/fake_email_with_attachments.cr | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/support/custom_template_email.cr b/spec/support/custom_template_email.cr index 69caeee..46f7890 100644 --- a/spec/support/custom_template_email.cr +++ b/spec/support/custom_template_email.cr @@ -7,7 +7,7 @@ class CustomTemplateEmail < Carbon::Email @headers = {} of String => String, @subject = "subject", @text_body : String? = nil, - @html_body : String? = nil + @html_body : String? = nil, ) end diff --git a/spec/support/fake_email.cr b/spec/support/fake_email.cr index fab39f0..7d0e68f 100644 --- a/spec/support/fake_email.cr +++ b/spec/support/fake_email.cr @@ -9,7 +9,7 @@ class FakeEmail < Carbon::Email @headers = {} of String => String, @subject = "subject", @text_body : String? = nil, - @html_body : String? = nil + @html_body : String? = nil, ) end diff --git a/spec/support/fake_email_with_attachments.cr b/spec/support/fake_email_with_attachments.cr index 6f59b81..6824e9e 100644 --- a/spec/support/fake_email_with_attachments.cr +++ b/spec/support/fake_email_with_attachments.cr @@ -9,7 +9,7 @@ class FakeEmailWithAttachments < Carbon::Email @headers = {} of String => String, @subject = "subject", @text_body : String? = nil, - @html_body : String? = nil + @html_body : String? = nil, ) end From a3c0375cc4b13266b67b3d05dacef415889e1e52 Mon Sep 17 00:00:00 2001 From: Chris Watson Date: Thu, 15 Jan 2026 13:08:23 -0700 Subject: [PATCH 3/3] refactor: consolidate SendGrid-specific options into sendgrid_options method All SendGrid-specific optional fields (asm, template_id, content, categories, send_at) are now handled consistently in a single sendgrid_options method instead of being split between params and extra_params. This improves code organization by clearly separating core email structure (params) from SendGrid API-specific options (sendgrid_options). --- spec/carbon_sendgrid_adapter_spec.cr | 82 +++++++++++++++++++-------- spec/support/custom_template_email.cr | 6 +- src/carbon_sendgrid_adapter.cr | 60 +++++++++----------- 3 files changed, 89 insertions(+), 59 deletions(-) diff --git a/spec/carbon_sendgrid_adapter_spec.cr b/spec/carbon_sendgrid_adapter_spec.cr index bb8b318..7c13066 100644 --- a/spec/carbon_sendgrid_adapter_spec.cr +++ b/spec/carbon_sendgrid_adapter_spec.cr @@ -18,10 +18,10 @@ describe Carbon::SendGridAdapter do {% end %} describe "errors" do - it "raises SendGridInvalidTemplateError if no template is defined in params" do + it "raises SendGridInvalidTemplateError if no template is defined" do expect_raises(Carbon::SendGridInvalidTemplateError) do email = FakeEmail.new - Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params + Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options end end end @@ -112,24 +112,24 @@ describe Carbon::SendGridAdapter do 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 [ + sendgrid_options_for(text_body: "text")["content"].should eq JSON.parse([{"type" => "text/plain", "value" => "text"}].to_json) + sendgrid_options_for(html_body: "html")["content"].should eq JSON.parse([{"type" => "text/html", "value" => "html"}].to_json) + sendgrid_options_for(text_body: "text", html_body: "html")["content"].should eq JSON.parse([ {"type" => "text/plain", "value" => "text"}, {"type" => "text/html", "value" => "html"}, - ] + ].to_json) end it "allows for a custom template_id" do custom_email = CustomTemplateEmail.new - params = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").params + options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options - params["template_id"].should eq("welcome-abc-123") + options["template_id"].should eq(JSON::Any.new("welcome-abc-123")) normal_email = FakeEmail.new(text_body: "0") - params = Carbon::SendGridAdapter::Email.new(normal_email, api_key: "fake_key").params + options = Carbon::SendGridAdapter::Email.new(normal_email, api_key: "fake_key").sendgrid_options - params.has_key?("template_id").should eq(false) + options.has_key?("template_id").should eq(false) end it "allows for custom template data" do @@ -146,14 +146,14 @@ describe Carbon::SendGridAdapter do it "passes over asm data on how to handle unsubscribes" do custom_email = CustomTemplateEmail.new - params = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").params + options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options - params["personalizations"].as(Array).first["dynamic_template_data"].should_not eq(nil) + options["asm"].should eq(JSON.parse({"group_id" => 1234, "groups_to_display" => [1234]}.to_json)) email = FakeEmail.new(text_body: "0") - params = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options - params["personalizations"].as(Array).first.has_key?("asm").should eq(false) + options.has_key?("asm").should eq(false) end it "handles attachments" do @@ -166,32 +166,61 @@ describe Carbon::SendGridAdapter do end describe "sendgrid specific features" do - it "includes categories in extra_params when defined" do + it "includes categories in sendgrid_options when defined" do email = EmailWithSendGridFeatures.new(text_body: "0") - extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options - extra["categories"].should eq(["welcome", "onboarding", "transactional"]) + options["categories"].should eq(JSON.parse(["welcome", "onboarding", "transactional"].to_json)) end it "does not include categories when not defined" do email = FakeEmail.new(text_body: "0") - extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options - extra.has_key?("categories").should eq(false) + options.has_key?("categories").should eq(false) end - it "includes send_at in extra_params when defined" do + it "includes send_at in sendgrid_options when defined" do email = EmailWithSendGridFeatures.new(text_body: "0") - extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options - extra["send_at"].should eq(1704067200_i64) + options["send_at"].should eq(JSON::Any.new(1704067200_i64)) end it "does not include send_at when not defined" do email = FakeEmail.new(text_body: "0") - extra = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").extra_params + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options - extra.has_key?("send_at").should eq(false) + options.has_key?("send_at").should eq(false) + end + + it "includes asm in sendgrid_options when defined" do + custom_email = CustomTemplateEmail.new + options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options + + options["asm"].should eq(JSON.parse({"group_id" => 1234, "groups_to_display" => [1234]}.to_json)) + end + + it "does not include asm when not defined" do + email = FakeEmail.new(text_body: "0") + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options + + options.has_key?("asm").should eq(false) + end + + it "includes template_id in sendgrid_options when defined" do + custom_email = CustomTemplateEmail.new + options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options + + options["template_id"].should eq(JSON::Any.new("welcome-abc-123")) + end + + it "includes content in sendgrid_options when template_id not defined" do + email = FakeEmail.new(text_body: "hello") + options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options + + options["content"].should eq(JSON.parse([{"type" => "text/plain", "value" => "hello"}].to_json)) + options.has_key?("template_id").should eq(false) end end end @@ -202,6 +231,11 @@ private def params_for(**email_attrs) Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params end +private def sendgrid_options_for(**email_attrs) + email = FakeEmail.new(**email_attrs) + Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options +end + private def send_email_to_send_grid(**email_attrs) api_key = ENV.fetch("SEND_GRID_API_KEY") email = FakeEmail.new(**email_attrs) diff --git a/spec/support/custom_template_email.cr b/spec/support/custom_template_email.cr index 46f7890..df23b6d 100644 --- a/spec/support/custom_template_email.cr +++ b/spec/support/custom_template_email.cr @@ -15,10 +15,10 @@ class CustomTemplateEmail < Carbon::Email "welcome-abc-123" end - def asm_data + def asm { - "group_id" => 12345, - "groups_to_display" => [12345], + "group_id" => 1234, + "groups_to_display" => [1234], } end diff --git a/src/carbon_sendgrid_adapter.cr b/src/carbon_sendgrid_adapter.cr index a7f0697..2abdcbf 100644 --- a/src/carbon_sendgrid_adapter.cr +++ b/src/carbon_sendgrid_adapter.cr @@ -27,15 +27,10 @@ class Carbon::SendGridAdapter < Carbon::Adapter end def deliver - # Merge params with extra_params for JSON serialization + # Merge core params with SendGrid-specific options json_data = JSON.parse(params.to_json).as_h - extra_params.each do |key, value| - case value - when Array(String) - json_data[key] = JSON::Any.new(value.map { |v| JSON::Any.new(v) }) - when Int64 - json_data[key] = JSON::Any.new(value) - end + sendgrid_options.each do |key, value| + json_data[key] = value end body = json_data.to_json @@ -47,6 +42,7 @@ class Carbon::SendGridAdapter < Carbon::Adapter end # :nodoc: + # Core email structure - SendGrid-specific options handled separately in sendgrid_options def params data = { "personalizations" => [personalizations], @@ -54,7 +50,6 @@ class Carbon::SendGridAdapter < Carbon::Adapter "from" => from, "headers" => headers, "reply_to" => reply_to_params, - "asm" => {"group_id" => 0, "groups_to_display" => [] of Int32}, "mail_settings" => {sandbox_mode: {enable: sandbox?}}, "attachments" => attachments, }.compact @@ -64,17 +59,29 @@ class Carbon::SendGridAdapter < Carbon::Adapter data.delete("attachments") end + data + end + + # :nodoc: + # All SendGrid-specific optional fields consolidated here. + # These are merged into the JSON body in deliver to handle flexible typing + # and keep the core params method clean. + def sendgrid_options + options = {} of String => JSON::Any + + # ASM (unsubscribe groups) + # https://docs.sendgrid.com/ui/sending-email/unsubscribe-groups if asm_data = email.asm - data = data.merge!({"asm" => asm_data}) - else - data.delete("asm") + options["asm"] = JSON.parse(asm_data.to_json) end + # Template ID or content (required: one or the other) + # https://docs.sendgrid.com/ui/sending-email/how-to-send-an-email-with-dynamic-transactional-templates if template_id = email.template_id - data = data.merge!({"template_id" => template_id}) + options["template_id"] = JSON::Any.new(template_id) else if content.size > 0 - data = data.merge({"content" => content}) + options["content"] = JSON.parse(content.to_json) else raise SendGridInvalidTemplateError.new <<-ERROR Unless a valid template_id is provided, a template is required. @@ -91,24 +98,19 @@ class Carbon::SendGridAdapter < Carbon::Adapter end end - data - end - - # :nodoc: - # Additional parameters that need flexible typing (categories, send_at) - # These are merged into the JSON body separately in deliver - def extra_params - extras = {} of String => Array(String) | Int64 - + # Categories for analytics + # https://docs.sendgrid.com/ui/analytics-and-reporting/categories if categories = email.categories - extras["categories"] = categories + options["categories"] = JSON::Any.new(categories.map { |c| JSON::Any.new(c) }) end + # Scheduled send time + # https://docs.sendgrid.com/ui/sending-email/scheduling-parameters if send_at = email.send_at - extras["send_at"] = send_at + options["send_at"] = JSON::Any.new(send_at) end - extras + options end private def reply_to_params : Hash(String, String)? @@ -159,12 +161,6 @@ class Carbon::SendGridAdapter < Carbon::Adapter to_send_grid_address([email.from]).first end - private def asm_data : Hash(String, String)? - if asm_data = email.asm - {"asm" => asm_data} - end - end - private def content : Array(Hash(String, String)) [ text_content,