Skip to content

Commit 414dab1

Browse files
authored
feat: add categories and send_at support (#26)
* 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 * style: add trailing commas in test fixture initializers * 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).
1 parent 94805b8 commit 414dab1

File tree

7 files changed

+176
-34
lines changed

7 files changed

+176
-34
lines changed

spec/carbon_sendgrid_adapter_spec.cr

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "./spec_helper"
2+
require "./support/email_with_sendgrid_features"
23

34
describe Carbon::SendGridAdapter do
45
{% if flag?("with-integration") %}
@@ -17,10 +18,10 @@ describe Carbon::SendGridAdapter do
1718
{% end %}
1819

1920
describe "errors" do
20-
it "raises SendGridInvalidTemplateError if no template is defined in params" do
21+
it "raises SendGridInvalidTemplateError if no template is defined" do
2122
expect_raises(Carbon::SendGridInvalidTemplateError) do
2223
email = FakeEmail.new
23-
Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params
24+
Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
2425
end
2526
end
2627
end
@@ -111,24 +112,24 @@ describe Carbon::SendGridAdapter do
111112
end
112113

113114
it "sets the content" do
114-
params_for(text_body: "text")["content"].should eq [{"type" => "text/plain", "value" => "text"}]
115-
params_for(html_body: "html")["content"].should eq [{"type" => "text/html", "value" => "html"}]
116-
params_for(text_body: "text", html_body: "html")["content"].should eq [
115+
sendgrid_options_for(text_body: "text")["content"].should eq JSON.parse([{"type" => "text/plain", "value" => "text"}].to_json)
116+
sendgrid_options_for(html_body: "html")["content"].should eq JSON.parse([{"type" => "text/html", "value" => "html"}].to_json)
117+
sendgrid_options_for(text_body: "text", html_body: "html")["content"].should eq JSON.parse([
117118
{"type" => "text/plain", "value" => "text"},
118119
{"type" => "text/html", "value" => "html"},
119-
]
120+
].to_json)
120121
end
121122

122123
it "allows for a custom template_id" do
123124
custom_email = CustomTemplateEmail.new
124-
params = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").params
125+
options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options
125126

126-
params["template_id"].should eq("welcome-abc-123")
127+
options["template_id"].should eq(JSON::Any.new("welcome-abc-123"))
127128

128129
normal_email = FakeEmail.new(text_body: "0")
129-
params = Carbon::SendGridAdapter::Email.new(normal_email, api_key: "fake_key").params
130+
options = Carbon::SendGridAdapter::Email.new(normal_email, api_key: "fake_key").sendgrid_options
130131

131-
params.has_key?("template_id").should eq(false)
132+
options.has_key?("template_id").should eq(false)
132133
end
133134

134135
it "allows for custom template data" do
@@ -145,14 +146,14 @@ describe Carbon::SendGridAdapter do
145146

146147
it "passes over asm data on how to handle unsubscribes" do
147148
custom_email = CustomTemplateEmail.new
148-
params = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").params
149+
options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options
149150

150-
params["personalizations"].as(Array).first["dynamic_template_data"].should_not eq(nil)
151+
options["asm"].should eq(JSON.parse({"group_id" => 1234, "groups_to_display" => [1234]}.to_json))
151152

152153
email = FakeEmail.new(text_body: "0")
153-
params = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params
154+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
154155

155-
params["personalizations"].as(Array).first.has_key?("asm").should eq(false)
156+
options.has_key?("asm").should eq(false)
156157
end
157158

158159
it "handles attachments" do
@@ -163,6 +164,65 @@ describe Carbon::SendGridAdapter do
163164
attachments.first["filename"].should eq("contract.pdf")
164165
Base64.decode_string(attachments.first["content"].to_s).should eq("Sign here")
165166
end
167+
168+
describe "sendgrid specific features" do
169+
it "includes categories in sendgrid_options when defined" do
170+
email = EmailWithSendGridFeatures.new(text_body: "0")
171+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
172+
173+
options["categories"].should eq(JSON.parse(["welcome", "onboarding", "transactional"].to_json))
174+
end
175+
176+
it "does not include categories when not defined" do
177+
email = FakeEmail.new(text_body: "0")
178+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
179+
180+
options.has_key?("categories").should eq(false)
181+
end
182+
183+
it "includes send_at in sendgrid_options when defined" do
184+
email = EmailWithSendGridFeatures.new(text_body: "0")
185+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
186+
187+
options["send_at"].should eq(JSON::Any.new(1704067200_i64))
188+
end
189+
190+
it "does not include send_at when not defined" do
191+
email = FakeEmail.new(text_body: "0")
192+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
193+
194+
options.has_key?("send_at").should eq(false)
195+
end
196+
197+
it "includes asm in sendgrid_options when defined" do
198+
custom_email = CustomTemplateEmail.new
199+
options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options
200+
201+
options["asm"].should eq(JSON.parse({"group_id" => 1234, "groups_to_display" => [1234]}.to_json))
202+
end
203+
204+
it "does not include asm when not defined" do
205+
email = FakeEmail.new(text_body: "0")
206+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
207+
208+
options.has_key?("asm").should eq(false)
209+
end
210+
211+
it "includes template_id in sendgrid_options when defined" do
212+
custom_email = CustomTemplateEmail.new
213+
options = Carbon::SendGridAdapter::Email.new(custom_email, api_key: "fake_key").sendgrid_options
214+
215+
options["template_id"].should eq(JSON::Any.new("welcome-abc-123"))
216+
end
217+
218+
it "includes content in sendgrid_options when template_id not defined" do
219+
email = FakeEmail.new(text_body: "hello")
220+
options = Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
221+
222+
options["content"].should eq(JSON.parse([{"type" => "text/plain", "value" => "hello"}].to_json))
223+
options.has_key?("template_id").should eq(false)
224+
end
225+
end
166226
end
167227
end
168228

@@ -171,6 +231,11 @@ private def params_for(**email_attrs)
171231
Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").params
172232
end
173233

234+
private def sendgrid_options_for(**email_attrs)
235+
email = FakeEmail.new(**email_attrs)
236+
Carbon::SendGridAdapter::Email.new(email, api_key: "fake_key").sendgrid_options
237+
end
238+
174239
private def send_email_to_send_grid(**email_attrs)
175240
api_key = ENV.fetch("SEND_GRID_API_KEY")
176241
email = FakeEmail.new(**email_attrs)

spec/support/custom_template_email.cr

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ class CustomTemplateEmail < Carbon::Email
77
@headers = {} of String => String,
88
@subject = "subject",
99
@text_body : String? = nil,
10-
@html_body : String? = nil
10+
@html_body : String? = nil,
1111
)
1212
end
1313

1414
def template_id
1515
"welcome-abc-123"
1616
end
1717

18-
def asm_data
18+
def asm
1919
{
20-
"group_id" => 12345,
21-
"groups_to_display" => [12345],
20+
"group_id" => 1234,
21+
"groups_to_display" => [1234],
2222
}
2323
end
2424

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
class EmailWithSendGridFeatures < 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+
def template_id
17+
"d-1234567890"
18+
end
19+
20+
def dynamic_template_data
21+
{
22+
"name" => "Test User",
23+
}
24+
end
25+
26+
def categories : Array(String)
27+
["welcome", "onboarding", "transactional"]
28+
end
29+
30+
def send_at : Int64
31+
1704067200_i64 # 2024-01-01 00:00:00 UTC
32+
end
33+
34+
from @from
35+
to @to
36+
cc @cc
37+
bcc @bcc
38+
subject @subject
39+
end

spec/support/fake_email.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class FakeEmail < Carbon::Email
99
@headers = {} of String => String,
1010
@subject = "subject",
1111
@text_body : String? = nil,
12-
@html_body : String? = nil
12+
@html_body : String? = nil,
1313
)
1414
end
1515

spec/support/fake_email_with_attachments.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class FakeEmailWithAttachments < Carbon::Email
99
@headers = {} of String => String,
1010
@subject = "subject",
1111
@text_body : String? = nil,
12-
@html_body : String? = nil
12+
@html_body : String? = nil,
1313
)
1414
end
1515

src/carbon_sendgrid_adapter.cr

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,13 @@ class Carbon::SendGridAdapter < Carbon::Adapter
2727
end
2828

2929
def deliver
30-
body = params.to_json
30+
# Merge core params with SendGrid-specific options
31+
json_data = JSON.parse(params.to_json).as_h
32+
sendgrid_options.each do |key, value|
33+
json_data[key] = value
34+
end
35+
body = json_data.to_json
36+
3137
client.post(MAIL_SEND_PATH, body: body).tap do |response|
3238
unless response.success?
3339
raise SendGridResponseFailedError.new(response.body)
@@ -36,14 +42,14 @@ class Carbon::SendGridAdapter < Carbon::Adapter
3642
end
3743

3844
# :nodoc:
45+
# Core email structure - SendGrid-specific options handled separately in sendgrid_options
3946
def params
4047
data = {
4148
"personalizations" => [personalizations],
4249
"subject" => email.subject,
4350
"from" => from,
4451
"headers" => headers,
4552
"reply_to" => reply_to_params,
46-
"asm" => {"group_id" => 0, "groups_to_display" => [] of Int32},
4753
"mail_settings" => {sandbox_mode: {enable: sandbox?}},
4854
"attachments" => attachments,
4955
}.compact
@@ -53,17 +59,29 @@ class Carbon::SendGridAdapter < Carbon::Adapter
5359
data.delete("attachments")
5460
end
5561

62+
data
63+
end
64+
65+
# :nodoc:
66+
# All SendGrid-specific optional fields consolidated here.
67+
# These are merged into the JSON body in deliver to handle flexible typing
68+
# and keep the core params method clean.
69+
def sendgrid_options
70+
options = {} of String => JSON::Any
71+
72+
# ASM (unsubscribe groups)
73+
# https://docs.sendgrid.com/ui/sending-email/unsubscribe-groups
5674
if asm_data = email.asm
57-
data = data.merge!({"asm" => asm_data})
58-
else
59-
data.delete("asm")
75+
options["asm"] = JSON.parse(asm_data.to_json)
6076
end
6177

78+
# Template ID or content (required: one or the other)
79+
# https://docs.sendgrid.com/ui/sending-email/how-to-send-an-email-with-dynamic-transactional-templates
6280
if template_id = email.template_id
63-
data = data.merge!({"template_id" => template_id})
81+
options["template_id"] = JSON::Any.new(template_id)
6482
else
6583
if content.size > 0
66-
data = data.merge({"content" => content})
84+
options["content"] = JSON.parse(content.to_json)
6785
else
6886
raise SendGridInvalidTemplateError.new <<-ERROR
6987
Unless a valid template_id is provided, a template is required.
@@ -80,7 +98,19 @@ class Carbon::SendGridAdapter < Carbon::Adapter
8098
end
8199
end
82100

83-
data
101+
# Categories for analytics
102+
# https://docs.sendgrid.com/ui/analytics-and-reporting/categories
103+
if categories = email.categories
104+
options["categories"] = JSON::Any.new(categories.map { |c| JSON::Any.new(c) })
105+
end
106+
107+
# Scheduled send time
108+
# https://docs.sendgrid.com/ui/sending-email/scheduling-parameters
109+
if send_at = email.send_at
110+
options["send_at"] = JSON::Any.new(send_at)
111+
end
112+
113+
options
84114
end
85115

86116
private def reply_to_params : Hash(String, String)?
@@ -131,12 +161,6 @@ class Carbon::SendGridAdapter < Carbon::Adapter
131161
to_send_grid_address([email.from]).first
132162
end
133163

134-
private def asm_data : Hash(String, String)?
135-
if asm_data = email.asm
136-
{"asm" => asm_data}
137-
end
138-
end
139-
140164
private def content : Array(Hash(String, String))
141165
[
142166
text_content,

src/carbon_sendgrid_extensions.cr

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ module Carbon::SendGridExtensions
2424
def asm
2525
nil
2626
end
27+
28+
# Define categories for your email to organize
29+
# and track analytics by category.
30+
# https://docs.sendgrid.com/ui/analytics-and-reporting/categories
31+
def categories : Array(String)?
32+
nil
33+
end
34+
35+
# Define a unix timestamp to schedule when
36+
# the email should be sent.
37+
# https://docs.sendgrid.com/ui/sending-email/scheduling-parameters
38+
def send_at : Int64?
39+
nil
40+
end
2741
end
2842

2943
class Carbon::Email

0 commit comments

Comments
 (0)