resend 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +40 -0
- data/app/controllers/action_mailbox/ingresses/resend/inbound_emails_controller.rb +95 -0
- data/lib/resend/action_mailbox/engine.rb +19 -0
- data/lib/resend/action_mailbox/message_builder.rb +162 -0
- data/lib/resend/action_mailbox.rb +5 -0
- data/lib/resend/contacts.rb +11 -3
- data/lib/resend/version.rb +1 -1
- data/lib/resend/webhooks.rb +16 -0
- data/lib/resend.rb +1 -0
- metadata +7 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aedf021421daf6882e301c0fb95bc9a9e5139aeefc7df4317c2c19516da45b39
|
|
4
|
+
data.tar.gz: f815d39241cc898ce249b945f58b2627673d561c5546eed275d5f5337c4d666b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79ee7639752c4cb1fb7d50c8aebf1959d998fb2aad158a5d6296dc38c0239cc898f6f65fd0bfce71ef5648eeadb0bf6ef17849234048270baed5896b81d9dee4
|
|
7
|
+
data.tar.gz: 2cab25a4b638ce11011e4fe54166677070cf104cbbca899d63d3db5a5995ce8644634ab9261c348901f5263214cc43121d3c8ba6f4eb9dac8e237baee2d1ccfc
|
data/README.md
CHANGED
|
@@ -106,3 +106,43 @@ mailer = UserMailer.with(user: u).welcome_email
|
|
|
106
106
|
mailer.deliver_now!
|
|
107
107
|
# => {:id=>"b8f94710-0d84-429c-925a-22d3d8f86916", from: 'you@yourdomain.io', to: ["example2@mail.com", "example1@mail.com"]}
|
|
108
108
|
```
|
|
109
|
+
|
|
110
|
+
# Rails and ActionMailbox support
|
|
111
|
+
|
|
112
|
+
This gem also provides an Action Mailbox ingress, so you can receive inbound emails through Resend.
|
|
113
|
+
|
|
114
|
+
Configure Action Mailbox to use the Resend ingress, and make sure your API key is set (it is used to fetch the full message and its attachments):
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# config/environments/production.rb
|
|
118
|
+
config.action_mailbox.ingress = :resend
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# config/initializers/resend.rb
|
|
123
|
+
Resend.api_key = "re_123456"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Resend delivers inbound emails as `email.received` webhooks. Create a webhook in the [Resend dashboard](https://resend.com/webhooks) (or via `Resend::Webhooks.create`) subscribed to the `email.received` event and pointed at your app:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
https://example.com/rails/action_mailbox/resend/inbound_emails
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Then store the webhook's signing secret (`whsec_...`) so the ingress can verify incoming requests. Either add it to your encrypted credentials with `bin/rails credentials:edit`:
|
|
133
|
+
|
|
134
|
+
```yml
|
|
135
|
+
action_mailbox:
|
|
136
|
+
resend_signing_secret: whsec_...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
or provide it through the `RESEND_INGRESS_SIGNING_SECRET` environment variable.
|
|
140
|
+
|
|
141
|
+
Each verified webhook is turned back into a full email — using Resend's raw message download when available, and otherwise rebuilding the message from the Received Emails and Attachments APIs — and handed to Action Mailbox for routing like any other ingress:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# app/mailboxes/application_mailbox.rb
|
|
145
|
+
class ApplicationMailbox < ActionMailbox::Base
|
|
146
|
+
routing /^support@/i => :support
|
|
147
|
+
end
|
|
148
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMailbox
|
|
4
|
+
module Ingresses
|
|
5
|
+
module Resend
|
|
6
|
+
# Ingests inbound emails delivered by Resend's +email.received+ webhook.
|
|
7
|
+
#
|
|
8
|
+
# Resend webhooks only carry the received email's metadata, so the full message
|
|
9
|
+
# (including any attachments) is fetched from the Resend API before being enqueued
|
|
10
|
+
# for routing. The webhook request is authenticated by verifying its Svix signature
|
|
11
|
+
# against the webhook's signing secret, which is stored in the
|
|
12
|
+
# +action_mailbox.resend_signing_secret+ Rails credential or the
|
|
13
|
+
# +RESEND_INGRESS_SIGNING_SECRET+ environment variable.
|
|
14
|
+
#
|
|
15
|
+
# Returns:
|
|
16
|
+
#
|
|
17
|
+
# - <tt>204 No Content</tt> if an inbound email is successfully recorded and enqueued for
|
|
18
|
+
# routing, or if the event is not an +email.received+ event and was ignored
|
|
19
|
+
# - <tt>401 Unauthorized</tt> if the request's signature could not be validated
|
|
20
|
+
# - <tt>404 Not Found</tt> if Action Mailbox is not configured to accept inbound emails
|
|
21
|
+
# from Resend
|
|
22
|
+
# - <tt>422 Unprocessable Entity</tt> if the request's payload is malformed
|
|
23
|
+
# - <tt>500 Server Error</tt> if the webhook signing secret is missing, the Resend API
|
|
24
|
+
# could not be reached, or one of the Active Record database, the Active Storage
|
|
25
|
+
# service, or the Active Job backend is misconfigured or unavailable
|
|
26
|
+
class InboundEmailsController < ActionMailbox::BaseController
|
|
27
|
+
before_action :authenticate
|
|
28
|
+
|
|
29
|
+
def create
|
|
30
|
+
return head :no_content unless email_received_event?
|
|
31
|
+
return head :unprocessable_entity if email_id.nil?
|
|
32
|
+
|
|
33
|
+
ActionMailbox::InboundEmail.create_and_extract_message_id! raw_email
|
|
34
|
+
head :no_content
|
|
35
|
+
rescue JSON::ParserError => e
|
|
36
|
+
logger.error e.message
|
|
37
|
+
head :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def raw_email
|
|
43
|
+
::Resend::ActionMailbox::MessageBuilder.new(email_id).raw_email
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def email_received_event?
|
|
47
|
+
event["type"] == "email.received"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def email_id
|
|
51
|
+
event.dig("data", "email_id")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def event
|
|
55
|
+
@event ||= JSON.parse(request.raw_post)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def authenticate
|
|
59
|
+
head :unauthorized unless authenticated?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def authenticated?
|
|
63
|
+
if signing_secret.present?
|
|
64
|
+
verified_signature?
|
|
65
|
+
else
|
|
66
|
+
raise ArgumentError, <<~MESSAGE.squish
|
|
67
|
+
Missing required Resend webhook signing secret. Set action_mailbox.resend_signing_secret
|
|
68
|
+
in your application's encrypted credentials or provide the RESEND_INGRESS_SIGNING_SECRET
|
|
69
|
+
environment variable.
|
|
70
|
+
MESSAGE
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def verified_signature?
|
|
75
|
+
::Resend::Webhooks.verify(
|
|
76
|
+
payload: request.raw_post,
|
|
77
|
+
headers: {
|
|
78
|
+
svix_id: request.headers["svix-id"],
|
|
79
|
+
svix_timestamp: request.headers["svix-timestamp"],
|
|
80
|
+
svix_signature: request.headers["svix-signature"]
|
|
81
|
+
},
|
|
82
|
+
webhook_secret: signing_secret
|
|
83
|
+
)
|
|
84
|
+
rescue RuntimeError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def signing_secret
|
|
89
|
+
Rails.application.credentials.dig(:action_mailbox, :resend_signing_secret) ||
|
|
90
|
+
ENV["RESEND_INGRESS_SIGNING_SECRET"]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Resend
|
|
6
|
+
module ActionMailbox
|
|
7
|
+
# Rails engine that routes Resend's +email.received+ webhooks to the
|
|
8
|
+
# Action Mailbox ingress controller.
|
|
9
|
+
class Engine < ::Rails::Engine
|
|
10
|
+
initializer "resend.action_mailbox.routes" do |app|
|
|
11
|
+
app.routes.append do
|
|
12
|
+
post "/rails/action_mailbox/resend/inbound_emails",
|
|
13
|
+
to: "action_mailbox/ingresses/resend/inbound_emails#create",
|
|
14
|
+
as: :rails_resend_inbound_emails
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mail"
|
|
4
|
+
|
|
5
|
+
module Resend
|
|
6
|
+
module ActionMailbox
|
|
7
|
+
# Rebuilds the full RFC 822 source of a received email so it can be ingested
|
|
8
|
+
# by Action Mailbox.
|
|
9
|
+
#
|
|
10
|
+
# Resend's +email.received+ webhook only carries metadata, so the message is
|
|
11
|
+
# fetched from the Received Emails API. When the API exposes a raw message
|
|
12
|
+
# download it is used as-is; otherwise the message is reconstructed as a
|
|
13
|
+
# Mail::Message from its parts, downloading each attachment through the
|
|
14
|
+
# attachments API.
|
|
15
|
+
class MessageBuilder
|
|
16
|
+
# Headers that describe the reconstructed MIME structure and therefore
|
|
17
|
+
# cannot be copied verbatim from the original message.
|
|
18
|
+
STRUCTURAL_HEADERS = %w[content-type content-transfer-encoding mime-version].freeze
|
|
19
|
+
|
|
20
|
+
# Envelope fields that are set from the API response when the original
|
|
21
|
+
# header of the same name is not available.
|
|
22
|
+
ENVELOPE_FIELDS = %i[from to cc bcc reply_to subject message_id].freeze
|
|
23
|
+
|
|
24
|
+
# @param email_id [String] The ID of the received email
|
|
25
|
+
def initialize(email_id)
|
|
26
|
+
@email_id = email_id
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [String] the full RFC 822 source of the received email
|
|
30
|
+
def raw_email
|
|
31
|
+
raw_download || rebuild_message.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def email
|
|
37
|
+
@email ||= Resend::Emails::Receiving.get(@email_id, html_format: "cid")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def raw_download
|
|
41
|
+
url = fetch(email[:raw], "download_url") unless blank?(email[:raw])
|
|
42
|
+
download(url) unless blank?(url)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rebuild_message
|
|
46
|
+
Mail.new.tap do |mail|
|
|
47
|
+
copy_headers(mail)
|
|
48
|
+
copy_envelope(mail)
|
|
49
|
+
add_bodies(mail)
|
|
50
|
+
add_attachments(mail)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def copy_headers(mail)
|
|
55
|
+
(email[:headers] || {}).each do |name, value|
|
|
56
|
+
next if STRUCTURAL_HEADERS.include?(name.to_s.downcase)
|
|
57
|
+
|
|
58
|
+
Array(value).each { |val| mail.header[name.to_s] = val }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def copy_envelope(mail)
|
|
63
|
+
ENVELOPE_FIELDS.each do |field|
|
|
64
|
+
value = email[field]
|
|
65
|
+
next if blank?(value) || mail.header[field.to_s.tr("_", "-")]
|
|
66
|
+
|
|
67
|
+
mail.public_send("#{field}=", value)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
mail.date ||= email[:created_at]
|
|
71
|
+
|
|
72
|
+
expose_bcc(mail)
|
|
73
|
+
copy_received_for(mail)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Mail omits Bcc when serializing a message by default, but Action Mailbox
|
|
77
|
+
# needs it to route the email.
|
|
78
|
+
def expose_bcc(mail)
|
|
79
|
+
bcc = mail.header["bcc"]
|
|
80
|
+
bcc.include_in_headers = true if bcc.respond_to?(:include_in_headers)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Preserve the addresses this email was received for (e.g. through a
|
|
84
|
+
# forwarding rule) so Action Mailbox can route on them.
|
|
85
|
+
def copy_received_for(mail)
|
|
86
|
+
Array(email[:received_for]).each { |address| mail.header["X-Original-To"] = address }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def add_bodies(mail)
|
|
90
|
+
html = presence(email[:html])
|
|
91
|
+
text = presence(email[:text])
|
|
92
|
+
|
|
93
|
+
if html
|
|
94
|
+
mail.text_part = build_part("text/plain", text) if text
|
|
95
|
+
mail.html_part = build_part("text/html", html)
|
|
96
|
+
elsif text
|
|
97
|
+
mail.content_type "text/plain; charset=UTF-8"
|
|
98
|
+
mail.body text
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_part(mime_type, content)
|
|
103
|
+
Mail::Part.new(content_type: "#{mime_type}; charset=UTF-8", body: content)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def add_attachments(mail)
|
|
107
|
+
Array(email[:attachments]).each { |meta| add_attachment(mail, meta) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def add_attachment(mail, meta)
|
|
111
|
+
details = Resend::Emails::Receiving::Attachments.get(email_id: @email_id, id: fetch(meta, "id"))
|
|
112
|
+
filename = fetch(meta, "filename") || details[:filename]
|
|
113
|
+
|
|
114
|
+
mail.attachments[filename] = {
|
|
115
|
+
mime_type: fetch(meta, "content_type") || details[:content_type],
|
|
116
|
+
content: download(details[:download_url])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
decorate_attachment(mail.attachments.last, meta)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def decorate_attachment(part, meta)
|
|
123
|
+
content_id = fetch(meta, "content_id")
|
|
124
|
+
part.content_id = normalize_content_id(content_id) unless blank?(content_id)
|
|
125
|
+
|
|
126
|
+
return unless fetch(meta, "content_disposition") == "inline"
|
|
127
|
+
|
|
128
|
+
part.content_disposition = "inline; filename=\"#{part.filename}\""
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def normalize_content_id(content_id)
|
|
132
|
+
content_id.start_with?("<") ? content_id : "<#{content_id}>"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def download(url)
|
|
136
|
+
response = HTTParty.get(url)
|
|
137
|
+
|
|
138
|
+
unless response.code == 200
|
|
139
|
+
raise Resend::Error::ServerError.new(
|
|
140
|
+
"Failed to download received email content (HTTP #{response.code})", response.code
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
response.body
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Nested objects come back from the API with string keys, but hand-built
|
|
148
|
+
# hashes (tests, console usage) often use symbols. Accept both.
|
|
149
|
+
def fetch(hash, key)
|
|
150
|
+
hash[key] || hash[key.to_sym]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def presence(value)
|
|
154
|
+
value unless blank?(value)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def blank?(value)
|
|
158
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/resend/contacts.rb
CHANGED
|
@@ -58,14 +58,22 @@ module Resend
|
|
|
58
58
|
# @example List contacts with pagination
|
|
59
59
|
# Resend::Contacts.list(limit: 10)
|
|
60
60
|
#
|
|
61
|
+
# @example List contacts scoped to a segment
|
|
62
|
+
# Resend::Contacts.list(segment_id: "seg_456", limit: 10)
|
|
63
|
+
#
|
|
61
64
|
# @example List contacts scoped to an audience
|
|
62
65
|
# Resend::Contacts.list(audience_id: "aud_456", limit: 10)
|
|
63
66
|
#
|
|
64
67
|
# https://resend.com/docs/api-reference/contacts/list-contacts
|
|
65
68
|
def list(params = {})
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
Resend::PaginationHelper.build_paginated_path("audiences/#{audience_id}/contacts",
|
|
69
|
+
path = if params[:audience_id]
|
|
70
|
+
audience_id = params[:audience_id]
|
|
71
|
+
Resend::PaginationHelper.build_paginated_path("audiences/#{audience_id}/contacts",
|
|
72
|
+
params.except(:audience_id))
|
|
73
|
+
elsif params[:segment_id]
|
|
74
|
+
segment_id = params[:segment_id]
|
|
75
|
+
Resend::PaginationHelper.build_paginated_path("segments/#{segment_id}/contacts",
|
|
76
|
+
params.except(:segment_id))
|
|
69
77
|
else
|
|
70
78
|
Resend::PaginationHelper.build_paginated_path("contacts", params)
|
|
71
79
|
end
|
data/lib/resend/version.rb
CHANGED
data/lib/resend/webhooks.rb
CHANGED
|
@@ -117,6 +117,22 @@ module Resend
|
|
|
117
117
|
Resend::Request.new(path, {}, "get").perform
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# Replay a webhook event
|
|
121
|
+
#
|
|
122
|
+
# Queues one more delivery of the event to the webhook. Manual replays do not schedule automatic retries.
|
|
123
|
+
#
|
|
124
|
+
# @param webhook_id [String] The webhook ID
|
|
125
|
+
# @param event_id [String] The webhook event ID
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash] The replayed webhook event id and object type
|
|
128
|
+
#
|
|
129
|
+
# @example
|
|
130
|
+
# Resend::Webhooks.replay_event("4dd369bc-aa82-4ff3-97de-514ae3000ee0", "msg_123")
|
|
131
|
+
def replay_event(webhook_id, event_id)
|
|
132
|
+
path = "webhooks/#{webhook_id}/events/#{event_id}/replay"
|
|
133
|
+
Resend::Request.new(path, {}, "post").perform
|
|
134
|
+
end
|
|
135
|
+
|
|
120
136
|
# Retrieve delivery attempts for a webhook event
|
|
121
137
|
#
|
|
122
138
|
# @param webhook_id [String] The webhook ID
|
data/lib/resend.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: resend
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Derich Pacheco
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: base64
|
|
@@ -38,7 +37,6 @@ dependencies:
|
|
|
38
37
|
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
39
|
version: 0.22.0
|
|
41
|
-
description:
|
|
42
40
|
email: carlosderich@gmail.com
|
|
43
41
|
executables: []
|
|
44
42
|
extensions: []
|
|
@@ -46,7 +44,11 @@ extra_rdoc_files: []
|
|
|
46
44
|
files:
|
|
47
45
|
- CHANGELOG.md
|
|
48
46
|
- README.md
|
|
47
|
+
- app/controllers/action_mailbox/ingresses/resend/inbound_emails_controller.rb
|
|
49
48
|
- lib/resend.rb
|
|
49
|
+
- lib/resend/action_mailbox.rb
|
|
50
|
+
- lib/resend/action_mailbox/engine.rb
|
|
51
|
+
- lib/resend/action_mailbox/message_builder.rb
|
|
50
52
|
- lib/resend/api_keys.rb
|
|
51
53
|
- lib/resend/automations.rb
|
|
52
54
|
- lib/resend/automations/runs.rb
|
|
@@ -85,7 +87,6 @@ homepage: https://github.com/resend/resend-ruby
|
|
|
85
87
|
licenses:
|
|
86
88
|
- MIT
|
|
87
89
|
metadata: {}
|
|
88
|
-
post_install_message:
|
|
89
90
|
rdoc_options: []
|
|
90
91
|
require_paths:
|
|
91
92
|
- lib
|
|
@@ -100,8 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
100
101
|
- !ruby/object:Gem::Version
|
|
101
102
|
version: '0'
|
|
102
103
|
requirements: []
|
|
103
|
-
rubygems_version:
|
|
104
|
-
signing_key:
|
|
104
|
+
rubygems_version: 4.0.16
|
|
105
105
|
specification_version: 4
|
|
106
106
|
summary: The Ruby and Rails SDK for resend.com
|
|
107
107
|
test_files: []
|