actionmailbox-resend 1.0.1
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +120 -0
- data/app/controllers/action_mailbox/resend/inbound_emails_controller.rb +425 -0
- data/config/routes.rb +5 -0
- data/lib/action_mailbox/resend/engine.rb +16 -0
- data/lib/action_mailbox/resend/version.rb +7 -0
- data/lib/action_mailbox/resend.rb +10 -0
- data/lib/actionmailbox-resend.rb +3 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b42b54e45b1412b1d80d387526c1014168237d095eb4357e34e005d50ed14fc4
|
|
4
|
+
data.tar.gz: 12b8f05d67bc727a91147a1cbca4d9b5b4b34e0ee8368eb9c054ab13f5cb223e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b41a106441f4bc4288b24e3857149598c827d3eca6364ff6038f0ed329f60622ebbefc3043d4e3c2039d131b9742f9e9c0e22c694e215109807f9dc5294a910b
|
|
7
|
+
data.tar.gz: 8a63047029a116a38346604163379594cde7f9fa5e32dcf602c7db357666c3d55027fa7e4e81ce3a16a8819cfda88cc1c7be26df83c00433b5f0714a13d4f4df
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 rcoenen
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ActionMailbox Resend
|
|
2
|
+
|
|
3
|
+
A Rails Engine providing [Resend](https://resend.com) email ingress support for [ActionMailbox](https://guides.rubyonrails.org/action_mailbox_basics.html).
|
|
4
|
+
|
|
5
|
+
This gem works with **any Rails application** that uses ActionMailbox. It receives webhooks from Resend's inbound email API, verifies signatures via Svix, reconstructs RFC822 MIME messages (including attachments and inline images), and delivers them to ActionMailbox for processing.
|
|
6
|
+
|
|
7
|
+
> **Note:** This gem was originally developed to add Resend support to [Chatwoot](https://chatwoot.com), but it's a general-purpose ActionMailbox ingress that works with any Rails application.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Webhook-based email ingestion via Resend's email receiving API
|
|
12
|
+
- Cryptographic signature verification using Svix
|
|
13
|
+
- Full attachment support (regular + inline images)
|
|
14
|
+
- Data URI to CID conversion for inline images
|
|
15
|
+
- Email threading support (In-Reply-To, References headers)
|
|
16
|
+
- SSRF protection with URL allow-listing
|
|
17
|
+
- Idempotency via webhook deduplication
|
|
18
|
+
- Request size limits and HTTP timeouts
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "actionmailbox-resend"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And then execute:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
### Environment Variables
|
|
37
|
+
|
|
38
|
+
Set the following environment variables:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
RESEND_API_KEY=re_xxx # Your Resend API key
|
|
42
|
+
RESEND_WEBHOOK_SECRET=whsec_xxx # Your Resend webhook signing secret
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Mount the Engine
|
|
46
|
+
|
|
47
|
+
In your `config/routes.rb`:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Rails.application.routes.draw do
|
|
51
|
+
mount ActionMailbox::Resend::Engine, at: "/rails/action_mailbox/resend"
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This creates the webhook endpoint at:
|
|
56
|
+
```
|
|
57
|
+
POST /rails/action_mailbox/resend/inbound_emails
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Resend Setup
|
|
61
|
+
|
|
62
|
+
1. **Enable Inbound Emails** in your Resend dashboard
|
|
63
|
+
2. **Configure Email Forwarding** to forward emails to your domain
|
|
64
|
+
3. **Add Webhook Endpoint**: `https://your-domain.com/rails/action_mailbox/resend/inbound_emails`
|
|
65
|
+
4. **Copy Webhook Signing Secret** to your `RESEND_WEBHOOK_SECRET` environment variable
|
|
66
|
+
|
|
67
|
+
## How It Works
|
|
68
|
+
|
|
69
|
+
1. **Webhook Reception**: Resend sends a webhook when an email is received
|
|
70
|
+
2. **Signature Verification**: The gem verifies the Svix signature to ensure authenticity
|
|
71
|
+
3. **Email Fetch**: Full email content is fetched from Resend's API (webhooks only contain email IDs)
|
|
72
|
+
4. **Attachment Download**: Attachments are fetched via a two-step process (metadata → download URL → content)
|
|
73
|
+
5. **RFC822 Construction**: The JSON email data is converted to a proper RFC822 MIME message
|
|
74
|
+
6. **ActionMailbox Delivery**: The message is submitted to ActionMailbox for processing
|
|
75
|
+
|
|
76
|
+
### RFC822 Reconstruction
|
|
77
|
+
|
|
78
|
+
Unlike other email providers that send emails in RFC822 format, Resend provides structured JSON. This gem handles the complex conversion including:
|
|
79
|
+
|
|
80
|
+
- Proper multipart MIME boundaries
|
|
81
|
+
- Content-Type headers for each part
|
|
82
|
+
- Content-Transfer-Encoding for attachments
|
|
83
|
+
- Content-ID for inline images
|
|
84
|
+
- Data URI to CID reference conversion
|
|
85
|
+
|
|
86
|
+
## Security
|
|
87
|
+
|
|
88
|
+
- **Svix Signature Verification**: All webhooks are cryptographically verified
|
|
89
|
+
- **SSRF Protection**: Attachment downloads are restricted to `*.resend.com` and `*.resend.app` domains
|
|
90
|
+
- **Size Limits**: 10MB max request size, 25MB max attachment size
|
|
91
|
+
- **Timeout Protection**: 5s connection timeout, 10s read timeout
|
|
92
|
+
- **Redirect Blocking**: Prevents redirect-based SSRF attacks
|
|
93
|
+
- **Idempotency**: 24-hour deduplication via `svix-id` header
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/rcoenen/actionmailbox-resend.git
|
|
101
|
+
cd actionmailbox-resend
|
|
102
|
+
bin/setup
|
|
103
|
+
bundle exec rspec
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rcoenen/actionmailbox-resend.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
113
|
+
|
|
114
|
+
## Related Links
|
|
115
|
+
|
|
116
|
+
- [Resend Inbound Email Docs](https://resend.com/docs/api-reference/emails/receive-email)
|
|
117
|
+
- [Resend Webhooks](https://resend.com/docs/webhooks)
|
|
118
|
+
- [Svix Webhook Verification](https://docs.svix.com/receiving/verifying-payloads/how)
|
|
119
|
+
- [Rails ActionMailbox](https://guides.rubyonrails.org/action_mailbox_basics.html)
|
|
120
|
+
- [Chatwoot](https://chatwoot.com)
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength
|
|
4
|
+
module ActionMailbox
|
|
5
|
+
module Resend
|
|
6
|
+
# Controller to handle inbound email webhooks from Resend
|
|
7
|
+
# Receives JSON webhook payload, verifies signature, fetches full email from Resend API,
|
|
8
|
+
# converts to RFC822, and processes via ActionMailbox
|
|
9
|
+
class InboundEmailsController < ActionController::Base
|
|
10
|
+
skip_forgery_protection
|
|
11
|
+
|
|
12
|
+
MAX_REQUEST_SIZE = 10.megabytes
|
|
13
|
+
MAX_ATTACHMENT_SIZE = 25.megabytes
|
|
14
|
+
ALLOWED_HOST_PATTERN = /\A[a-z0-9-]+\.resend\.(com|app)\z/i
|
|
15
|
+
|
|
16
|
+
before_action :verify_authenticity
|
|
17
|
+
before_action :validate_request_size
|
|
18
|
+
before_action :check_idempotency
|
|
19
|
+
|
|
20
|
+
# rubocop:disable Metrics/AbcSize
|
|
21
|
+
def create
|
|
22
|
+
# Parse webhook payload
|
|
23
|
+
payload = JSON.parse(request.body.read)
|
|
24
|
+
|
|
25
|
+
# Only process email.received events
|
|
26
|
+
return head :ok unless payload['type'] == 'email.received'
|
|
27
|
+
|
|
28
|
+
# Extract email_id from webhook
|
|
29
|
+
email_id = payload.dig('data', 'email_id')
|
|
30
|
+
return head(:unprocessable_entity) if email_id.blank?
|
|
31
|
+
|
|
32
|
+
# Fetch full email from Resend API
|
|
33
|
+
email_data = fetch_email_from_resend(email_id)
|
|
34
|
+
return head(:internal_server_error) if email_data.nil?
|
|
35
|
+
|
|
36
|
+
# Add email_id to email_data since Resend API doesn't include it in the response
|
|
37
|
+
email_data['email_id'] = email_id
|
|
38
|
+
|
|
39
|
+
# Build RFC822 MIME message with full content
|
|
40
|
+
mime_message = build_rfc822_message(email_data)
|
|
41
|
+
|
|
42
|
+
# Submit to ActionMailbox for processing
|
|
43
|
+
ActionMailbox::InboundEmail.create_and_extract_message_id!(mime_message)
|
|
44
|
+
|
|
45
|
+
# Mark webhook as processed to prevent duplicates
|
|
46
|
+
mark_processed(request.headers['svix-id'])
|
|
47
|
+
|
|
48
|
+
head :ok
|
|
49
|
+
rescue JSON::ParserError => e
|
|
50
|
+
Rails.logger.error("Resend webhook: Invalid JSON - #{e.message}")
|
|
51
|
+
head :bad_request
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Rails.logger.error("Resend webhook: Processing error - #{e.message}")
|
|
54
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
55
|
+
head :internal_server_error
|
|
56
|
+
end
|
|
57
|
+
# rubocop:enable Metrics/AbcSize
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_request_size
|
|
62
|
+
return if request.content_length.nil?
|
|
63
|
+
|
|
64
|
+
return unless request.content_length > MAX_REQUEST_SIZE
|
|
65
|
+
|
|
66
|
+
Rails.logger.warn("Resend webhook rejected: request too large (#{request.content_length} bytes)")
|
|
67
|
+
head :payload_too_large
|
|
68
|
+
false # Halt the filter chain
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def check_idempotency
|
|
72
|
+
svix_id = request.headers['svix-id']
|
|
73
|
+
return if svix_id.blank?
|
|
74
|
+
|
|
75
|
+
return unless already_processed?(svix_id)
|
|
76
|
+
|
|
77
|
+
Rails.logger.info("Resend webhook skipped: duplicate svix-id #{svix_id}")
|
|
78
|
+
head :conflict
|
|
79
|
+
false # Halt the filter chain
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def already_processed?(svix_id)
|
|
83
|
+
cache_key = "resend:webhook:#{svix_id}"
|
|
84
|
+
Rails.cache.read(cache_key).present?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mark_processed(svix_id)
|
|
88
|
+
return if svix_id.blank?
|
|
89
|
+
|
|
90
|
+
cache_key = "resend:webhook:#{svix_id}"
|
|
91
|
+
Rails.cache.write(cache_key, true, expires_in: 24.hours)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_authenticity
|
|
95
|
+
# Get raw body for signature verification
|
|
96
|
+
raw_body = request.body.read
|
|
97
|
+
request.body.rewind # Rewind so it can be read again
|
|
98
|
+
|
|
99
|
+
# Extract Svix headers
|
|
100
|
+
headers = {
|
|
101
|
+
'svix-id' => request.headers['svix-id'],
|
|
102
|
+
'svix-timestamp' => request.headers['svix-timestamp'],
|
|
103
|
+
'svix-signature' => request.headers['svix-signature']
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return if valid_signature?(raw_body, headers)
|
|
107
|
+
|
|
108
|
+
Rails.logger.warn('Resend webhook: Invalid signature')
|
|
109
|
+
head :unauthorized
|
|
110
|
+
false # Halt the filter chain
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def valid_signature?(body, headers)
|
|
114
|
+
return false if headers['svix-signature'].blank?
|
|
115
|
+
|
|
116
|
+
secret = ENV.fetch('RESEND_WEBHOOK_SECRET', nil)
|
|
117
|
+
return false if secret.blank?
|
|
118
|
+
|
|
119
|
+
# Use Svix to verify webhook signature
|
|
120
|
+
require 'svix'
|
|
121
|
+
wh = Svix::Webhook.new(secret)
|
|
122
|
+
wh.verify(body, headers)
|
|
123
|
+
true
|
|
124
|
+
rescue Svix::WebhookVerificationError => e
|
|
125
|
+
Rails.logger.error("Resend webhook verification failed: #{e.message}")
|
|
126
|
+
false
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
Rails.logger.error("Resend webhook verification error: #{e.message}")
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fetch_email_from_resend(email_id)
|
|
133
|
+
api_key = ENV.fetch('RESEND_API_KEY', nil)
|
|
134
|
+
if api_key.blank?
|
|
135
|
+
Rails.logger.error('Resend webhook: RESEND_API_KEY not configured')
|
|
136
|
+
return nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
uri = URI("https://api.resend.com/emails/receiving/#{email_id}")
|
|
140
|
+
request = Net::HTTP::Get.new(uri)
|
|
141
|
+
request['Authorization'] = "Bearer #{api_key}"
|
|
142
|
+
request['Content-Type'] = 'application/json'
|
|
143
|
+
|
|
144
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
145
|
+
http.request(request)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
149
|
+
|
|
150
|
+
Rails.logger.error("Resend API error: #{response.code} - #{response.body}")
|
|
151
|
+
nil
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
Rails.logger.error("Failed to fetch email from Resend: #{e.message}")
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_rfc822_message(email_data)
|
|
158
|
+
# Use Mail gem to build proper RFC822 MIME message
|
|
159
|
+
require 'mail'
|
|
160
|
+
require 'base64'
|
|
161
|
+
|
|
162
|
+
mail = Mail.new
|
|
163
|
+
set_mail_headers(mail, email_data)
|
|
164
|
+
|
|
165
|
+
# Determine if we have attachments
|
|
166
|
+
has_attachments = email_data['attachments'].present?
|
|
167
|
+
|
|
168
|
+
# Convert data URI images to cid: references if we have inline attachments
|
|
169
|
+
# Resend embeds images as data URIs in HTML, but we need cid: references for proper email structure
|
|
170
|
+
if has_attachments && email_data['html'].present?
|
|
171
|
+
email_data['html'] = convert_data_uris_to_cid(email_data['html'], email_data['attachments'], email_data['email_id'])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Build email body structure
|
|
175
|
+
set_mail_body(mail, email_data, has_attachments)
|
|
176
|
+
|
|
177
|
+
# Add attachments if present - this will automatically convert to multipart/mixed
|
|
178
|
+
add_all_attachments(mail, email_data) if has_attachments
|
|
179
|
+
|
|
180
|
+
mail.to_s
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# rubocop:disable Metrics/AbcSize
|
|
184
|
+
def set_mail_headers(mail, email_data)
|
|
185
|
+
mail.from = email_data['from']
|
|
186
|
+
mail.to = email_data['to']
|
|
187
|
+
mail.cc = email_data['cc'] if email_data['cc'].present?
|
|
188
|
+
mail.bcc = email_data['bcc'] if email_data['bcc'].present?
|
|
189
|
+
mail.subject = email_data['subject'] || '(no subject)'
|
|
190
|
+
|
|
191
|
+
# Add threading headers if available
|
|
192
|
+
mail.message_id = email_data['message_id'] if email_data['message_id'].present?
|
|
193
|
+
mail.in_reply_to = email_data.dig('headers', 'in-reply-to') if email_data.dig('headers', 'in-reply-to').present?
|
|
194
|
+
mail.references = email_data.dig('headers', 'references') if email_data.dig('headers', 'references').present?
|
|
195
|
+
end
|
|
196
|
+
# rubocop:enable Metrics/AbcSize
|
|
197
|
+
|
|
198
|
+
def set_mail_body(mail, email_data, _has_attachments)
|
|
199
|
+
if email_data['html'].present? && email_data['text'].present?
|
|
200
|
+
# Both text and HTML - create multipart/alternative
|
|
201
|
+
mail.text_part = create_text_part(email_data['text'])
|
|
202
|
+
mail.html_part = create_html_part(email_data['html'])
|
|
203
|
+
elsif email_data['html'].present?
|
|
204
|
+
# HTML only
|
|
205
|
+
mail.content_type = 'text/html; charset=UTF-8'
|
|
206
|
+
mail.body = email_data['html']
|
|
207
|
+
else
|
|
208
|
+
# Text only
|
|
209
|
+
mail.content_type = 'text/plain; charset=UTF-8'
|
|
210
|
+
mail.body = email_data['text'] || '(no body)'
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def create_text_part(text_content)
|
|
215
|
+
Mail::Part.new do
|
|
216
|
+
content_type 'text/plain; charset=UTF-8'
|
|
217
|
+
body text_content
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def create_html_part(html_content)
|
|
222
|
+
Mail::Part.new do
|
|
223
|
+
content_type 'text/html; charset=UTF-8'
|
|
224
|
+
body html_content
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def add_all_attachments(mail, email_data)
|
|
229
|
+
total = email_data['attachments'].size
|
|
230
|
+
failed = 0
|
|
231
|
+
|
|
232
|
+
email_data['attachments'].each do |attachment_meta|
|
|
233
|
+
result = add_attachment_to_mail(mail, email_data['email_id'], attachment_meta)
|
|
234
|
+
failed += 1 if result.nil?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
Rails.logger.info("Resend attachments: #{total - failed}/#{total} successful")
|
|
238
|
+
Rails.logger.warn("Resend: #{failed} attachments failed to download") if failed.positive?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def add_attachment_to_mail(mail, email_id, attachment_meta)
|
|
242
|
+
# Fetch attachment content from Resend API
|
|
243
|
+
attachment_content = fetch_attachment_from_resend(email_id, attachment_meta['id'])
|
|
244
|
+
return nil if attachment_content.nil?
|
|
245
|
+
|
|
246
|
+
# Handle inline vs regular attachments differently
|
|
247
|
+
# Inline attachments must use mail.attachments.inline[] to set Content-Disposition header
|
|
248
|
+
# This allows MailboxHelper to detect them via .inline? and convert cid: to ActiveStorage URLs
|
|
249
|
+
if attachment_meta['content_disposition'] == 'inline'
|
|
250
|
+
add_inline_attachment(mail, attachment_meta, attachment_content)
|
|
251
|
+
else
|
|
252
|
+
add_regular_attachment(mail, attachment_meta, attachment_content)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
true # Return success
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
Rails.logger.error("Failed to add attachment #{attachment_meta['id']}: #{e.message}")
|
|
258
|
+
nil # Return failure
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def add_inline_attachment(mail, attachment_meta, attachment_content)
|
|
262
|
+
mail.attachments.inline[attachment_meta['filename']] = {
|
|
263
|
+
content_type: attachment_meta['content_type'],
|
|
264
|
+
content: attachment_content
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Set Content-ID for cid: reference matching
|
|
268
|
+
return if attachment_meta['content_id'].blank?
|
|
269
|
+
|
|
270
|
+
# Strip angle brackets as Mail gem adds them automatically
|
|
271
|
+
# Resend provides: "<content-id>", Mail gem expects: "content-id"
|
|
272
|
+
content_id_without_brackets = attachment_meta['content_id'].gsub(/[<>]/, '')
|
|
273
|
+
mail.attachments.last.content_id = content_id_without_brackets
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def add_regular_attachment(mail, attachment_meta, attachment_content)
|
|
277
|
+
mail.attachments[attachment_meta['filename']] = {
|
|
278
|
+
content_type: attachment_meta['content_type'],
|
|
279
|
+
content: attachment_content
|
|
280
|
+
}
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
284
|
+
def fetch_attachment_from_resend(email_id, attachment_id)
|
|
285
|
+
api_key = ENV.fetch('RESEND_API_KEY', nil)
|
|
286
|
+
return nil if api_key.blank?
|
|
287
|
+
|
|
288
|
+
# Step 1: Get attachment metadata including download_url
|
|
289
|
+
metadata_uri = URI("https://api.resend.com/emails/receiving/#{email_id}/attachments/#{attachment_id}")
|
|
290
|
+
metadata_request = Net::HTTP::Get.new(metadata_uri)
|
|
291
|
+
metadata_request['Authorization'] = "Bearer #{api_key}"
|
|
292
|
+
|
|
293
|
+
metadata_response = Net::HTTP.start(metadata_uri.hostname, metadata_uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
294
|
+
http.request(metadata_request)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
unless metadata_response.is_a?(Net::HTTPSuccess)
|
|
298
|
+
Rails.logger.error("Resend API error fetching attachment metadata: #{metadata_response.code} - #{metadata_response.body}")
|
|
299
|
+
return nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
metadata = JSON.parse(metadata_response.body)
|
|
303
|
+
download_url = metadata['download_url']
|
|
304
|
+
|
|
305
|
+
if download_url.blank?
|
|
306
|
+
Rails.logger.error('No download_url in attachment metadata')
|
|
307
|
+
return nil
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Validate download URL to prevent SSRF attacks
|
|
311
|
+
unless valid_resend_url?(download_url)
|
|
312
|
+
Rails.logger.error("Invalid or unauthorized download URL: #{download_url}")
|
|
313
|
+
return nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Step 2: Download actual file content from the download_url
|
|
317
|
+
download_uri = URI(download_url)
|
|
318
|
+
|
|
319
|
+
# Check attachment size before downloading
|
|
320
|
+
head_response = Net::HTTP.start(download_uri.hostname, download_uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
321
|
+
http.head(download_uri.request_uri)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
if head_response['content-length'].present?
|
|
325
|
+
content_length = head_response['content-length'].to_i
|
|
326
|
+
if content_length > MAX_ATTACHMENT_SIZE
|
|
327
|
+
Rails.logger.warn("Resend attachment too large: #{content_length} bytes (max: #{MAX_ATTACHMENT_SIZE})")
|
|
328
|
+
return nil
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
download_response = Net::HTTP.start(download_uri.hostname, download_uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
|
|
333
|
+
http.get(download_uri.request_uri)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Block redirects to prevent SSRF attacks
|
|
337
|
+
if download_response.is_a?(Net::HTTPRedirection)
|
|
338
|
+
Rails.logger.warn("Resend attachment download redirect blocked: #{download_url}")
|
|
339
|
+
return nil
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
return download_response.body if download_response.is_a?(Net::HTTPSuccess)
|
|
343
|
+
|
|
344
|
+
Rails.logger.error("Failed to download attachment from URL: #{download_response.code}")
|
|
345
|
+
nil
|
|
346
|
+
rescue StandardError => e
|
|
347
|
+
Rails.logger.error("Failed to fetch attachment from Resend: #{e.message}")
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
351
|
+
|
|
352
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
353
|
+
def convert_data_uris_to_cid(html, attachments, email_id)
|
|
354
|
+
# Resend embeds inline images as data URIs in HTML
|
|
355
|
+
# We need to convert them to cid: references that match the attachment Content-IDs
|
|
356
|
+
# This allows MailboxHelper to convert them to ActiveStorage URLs
|
|
357
|
+
|
|
358
|
+
return html if html.blank? || attachments.blank?
|
|
359
|
+
|
|
360
|
+
# Find inline image attachments with content_ids
|
|
361
|
+
inline_attachments = attachments.select { |a| a['content_disposition'] == 'inline' && a['content_id'].present? }
|
|
362
|
+
return html unless inline_attachments.any?
|
|
363
|
+
|
|
364
|
+
# Build a map of base64-encoded image data to content_ids by fetching each attachment
|
|
365
|
+
data_uri_to_cid = build_data_uri_map(inline_attachments, email_id)
|
|
366
|
+
|
|
367
|
+
# Replace data URI images with cid: references
|
|
368
|
+
# Match: <img src="data:image/TYPE;base64,BASE64DATA" ...>
|
|
369
|
+
html.gsub(%r{(<img[^>]+src=")data:image/[^;]+;base64,([^"]+)("[^>]*>)}i) do |match|
|
|
370
|
+
prefix = ::Regexp.last_match(1)
|
|
371
|
+
base64_data = ::Regexp.last_match(2)
|
|
372
|
+
suffix = ::Regexp.last_match(3)
|
|
373
|
+
|
|
374
|
+
# Find matching content_id for this base64 data
|
|
375
|
+
content_id = data_uri_to_cid[base64_data]
|
|
376
|
+
|
|
377
|
+
if content_id
|
|
378
|
+
# Replace with cid: reference
|
|
379
|
+
"#{prefix}cid:#{content_id}#{suffix}"
|
|
380
|
+
else
|
|
381
|
+
# No matching attachment found, leave as data URI
|
|
382
|
+
match
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
rescue StandardError => e
|
|
386
|
+
Rails.logger.error("Failed to convert data URIs to cid references: #{e.message}")
|
|
387
|
+
html # Return original HTML on error
|
|
388
|
+
end
|
|
389
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
390
|
+
|
|
391
|
+
def build_data_uri_map(inline_attachments, email_id)
|
|
392
|
+
data_uri_to_cid = {}
|
|
393
|
+
inline_attachments.each do |attachment|
|
|
394
|
+
attachment_content = fetch_attachment_from_resend(email_id, attachment['id'])
|
|
395
|
+
next if attachment_content.nil?
|
|
396
|
+
|
|
397
|
+
# Encode as base64 to match data URI format
|
|
398
|
+
base64_content = Base64.strict_encode64(attachment_content)
|
|
399
|
+
# Strip angle brackets from content_id (Resend provides "<cid>", we need "cid")
|
|
400
|
+
content_id = attachment['content_id'].gsub(/[<>]/, '')
|
|
401
|
+
data_uri_to_cid[base64_content] = content_id
|
|
402
|
+
end
|
|
403
|
+
data_uri_to_cid
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def valid_resend_url?(url)
|
|
407
|
+
return false if url.blank?
|
|
408
|
+
|
|
409
|
+
uri = URI.parse(url)
|
|
410
|
+
|
|
411
|
+
# Check scheme - only HTTPS allowed
|
|
412
|
+
return false unless uri.scheme == 'https'
|
|
413
|
+
|
|
414
|
+
# Check host against Resend domain pattern (*.resend.com)
|
|
415
|
+
return false unless uri.host&.match?(ALLOWED_HOST_PATTERN)
|
|
416
|
+
|
|
417
|
+
true
|
|
418
|
+
rescue URI::InvalidURIError => e
|
|
419
|
+
Rails.logger.error("Invalid URL in Resend webhook: #{url} - #{e.message}")
|
|
420
|
+
false
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
# rubocop:enable Metrics/ClassLength
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionMailbox
|
|
4
|
+
module Resend
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace ActionMailbox::Resend
|
|
7
|
+
|
|
8
|
+
# Load the engine's routes automatically
|
|
9
|
+
initializer "actionmailbox-resend.load_routes" do |app|
|
|
10
|
+
app.routes.append do
|
|
11
|
+
# Allow mounting at a custom path if not already mounted
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: actionmailbox-resend
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- rcoenen
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: svix
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec-rails
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: webmock
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.18'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.18'
|
|
68
|
+
description: A Rails Engine providing Resend email ingress support for ActionMailbox.
|
|
69
|
+
Receives webhooks from Resend, verifies signatures via Svix, reconstructs RFC822
|
|
70
|
+
MIME messages, and delivers to ActionMailbox.
|
|
71
|
+
email:
|
|
72
|
+
- rcoenen@users.noreply.github.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- LICENSE.txt
|
|
78
|
+
- README.md
|
|
79
|
+
- app/controllers/action_mailbox/resend/inbound_emails_controller.rb
|
|
80
|
+
- config/routes.rb
|
|
81
|
+
- lib/action_mailbox/resend.rb
|
|
82
|
+
- lib/action_mailbox/resend/engine.rb
|
|
83
|
+
- lib/action_mailbox/resend/version.rb
|
|
84
|
+
- lib/actionmailbox-resend.rb
|
|
85
|
+
homepage: https://github.com/rcoenen/actionmailbox-resend
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
homepage_uri: https://github.com/rcoenen/actionmailbox-resend
|
|
90
|
+
source_code_uri: https://github.com/rcoenen/actionmailbox-resend
|
|
91
|
+
changelog_uri: https://github.com/rcoenen/actionmailbox-resend/blob/main/CHANGELOG.md
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: 3.1.0
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 3.7.2
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Resend email ingress for ActionMailbox
|
|
109
|
+
test_files: []
|