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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActionMailbox::Resend::Engine.routes.draw do
4
+ post "/inbound_emails" => "inbound_emails#create", as: :inbound_emails
5
+ end
@@ -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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMailbox
4
+ module Resend
5
+ VERSION = "1.0.1"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resend/version"
4
+ require_relative "resend/engine"
5
+
6
+ module ActionMailbox
7
+ module Resend
8
+ class Error < StandardError; end
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailbox/resend"
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: []