actionmailbox-resend 1.0.5

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: bd1828862b0a6a228916314fd02c579d573596972ad0cb0504f8ae96fd72ea36
4
+ data.tar.gz: fe4b3c151880d5add134675a66497f4e6a76883e87ededfe308850fb365288af
5
+ SHA512:
6
+ metadata.gz: b79b0224ec90f1cec399ed59519d77e10067bb1d9a6fd1962b534b6ec10513de5425124ec128d4c9197f6f4b09ebfe381ee0e273f2746dfdb561505b32048ec4
7
+ data.tar.gz: 6967142b8eb1eb58e72662fd0f09f0f4c8f3cf037260d1120170ce1312fd32445b38816ddadfeac83b2dd4f868ad9840c6d165d28491b1d826efe10285e67f00
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,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ # rubocop:disable Metrics/ClassLength
6
+ module ActionMailbox
7
+ module Resend
8
+ # Controller to handle inbound email webhooks from Resend
9
+ # Receives JSON webhook payload, verifies signature, fetches full email from Resend API,
10
+ # converts to RFC822, and processes via ActionMailbox
11
+ class InboundEmailsController < ActionController::Base
12
+ skip_forgery_protection
13
+
14
+ MAX_REQUEST_SIZE = 10.megabytes
15
+ MAX_ATTACHMENT_SIZE = 25.megabytes
16
+ ALLOWED_HOST_PATTERN = /\A[a-z0-9-]+\.resend\.(com|app)\z/i
17
+
18
+ before_action :verify_authenticity
19
+ before_action :validate_request_size
20
+ before_action :check_idempotency
21
+
22
+ # rubocop:disable Metrics/AbcSize
23
+ def create
24
+ # Parse webhook payload
25
+ payload = JSON.parse(request.body.read)
26
+
27
+ # Only process email.received events
28
+ return head :ok unless payload['type'] == 'email.received'
29
+
30
+ # Extract email_id from webhook
31
+ email_id = payload.dig('data', 'email_id')
32
+ return head(422) if email_id.blank?
33
+
34
+ # Fetch full email from Resend API
35
+ email_data = fetch_email_from_resend(email_id)
36
+ return head(:internal_server_error) if email_data.nil?
37
+
38
+ # Add email_id to email_data since Resend API doesn't include it in the response
39
+ email_data['email_id'] = email_id
40
+
41
+ # Build RFC822 MIME message with full content
42
+ mime_message = build_rfc822_message(email_data)
43
+
44
+ # Submit to ActionMailbox for processing
45
+ ActionMailbox::InboundEmail.create_and_extract_message_id!(mime_message)
46
+
47
+ # Mark webhook as processed to prevent duplicates
48
+ mark_processed(request.headers['svix-id'])
49
+
50
+ head :ok
51
+ rescue JSON::ParserError => e
52
+ Rails.logger.error("Resend webhook: Invalid JSON - #{e.message}")
53
+ head :bad_request
54
+ rescue StandardError => e
55
+ Rails.logger.error("Resend webhook: Processing error - #{e.message}")
56
+ Rails.logger.error(e.backtrace.join("\n"))
57
+ head :internal_server_error
58
+ end
59
+ # rubocop:enable Metrics/AbcSize
60
+
61
+ private
62
+
63
+ def validate_request_size
64
+ return if request.content_length.nil?
65
+
66
+ return unless request.content_length > MAX_REQUEST_SIZE
67
+
68
+ Rails.logger.warn("Resend webhook rejected: request too large (#{request.content_length} bytes)")
69
+ head :payload_too_large
70
+ false # Halt the filter chain
71
+ end
72
+
73
+ def check_idempotency
74
+ svix_id = request.headers['svix-id']
75
+ return if svix_id.blank?
76
+
77
+ return unless already_processed?(svix_id)
78
+
79
+ Rails.logger.info("Resend webhook skipped: duplicate svix-id #{svix_id}")
80
+ head :conflict
81
+ false # Halt the filter chain
82
+ end
83
+
84
+ def already_processed?(svix_id)
85
+ cache_key = "resend:webhook:#{svix_id}"
86
+ Rails.cache.read(cache_key).present?
87
+ end
88
+
89
+ def mark_processed(svix_id)
90
+ return if svix_id.blank?
91
+
92
+ cache_key = "resend:webhook:#{svix_id}"
93
+ Rails.cache.write(cache_key, true, expires_in: 24.hours)
94
+ end
95
+
96
+ def verify_authenticity
97
+ # Get raw body for signature verification
98
+ raw_body = request.body.read
99
+ request.body.rewind # Rewind so it can be read again
100
+
101
+ # Extract Svix headers
102
+ headers = {
103
+ 'svix-id' => request.headers['svix-id'],
104
+ 'svix-timestamp' => request.headers['svix-timestamp'],
105
+ 'svix-signature' => request.headers['svix-signature']
106
+ }
107
+
108
+ return if valid_signature?(raw_body, headers)
109
+
110
+ Rails.logger.warn('Resend webhook: Invalid signature')
111
+ head :unauthorized
112
+ false # Halt the filter chain
113
+ end
114
+
115
+ def valid_signature?(body, headers)
116
+ return false if headers['svix-signature'].blank?
117
+
118
+ secret = ENV.fetch('RESEND_WEBHOOK_SECRET', nil)
119
+ return false if secret.blank?
120
+
121
+ # Use Svix to verify webhook signature
122
+ require 'svix'
123
+ wh = Svix::Webhook.new(secret)
124
+ wh.verify(body, headers)
125
+ true
126
+ rescue Svix::WebhookVerificationError => e
127
+ Rails.logger.error("Resend webhook verification failed: #{e.message}")
128
+ false
129
+ rescue StandardError => e
130
+ Rails.logger.error("Resend webhook verification error: #{e.message}")
131
+ false
132
+ end
133
+
134
+ def fetch_email_from_resend(email_id)
135
+ api_key = ENV.fetch('RESEND_API_KEY', nil)
136
+ if api_key.blank?
137
+ Rails.logger.error('Resend webhook: RESEND_API_KEY not configured')
138
+ return nil
139
+ end
140
+
141
+ uri = URI("https://api.resend.com/emails/receiving/#{email_id}")
142
+ request = Net::HTTP::Get.new(uri)
143
+ request['Authorization'] = "Bearer #{api_key}"
144
+ request['Content-Type'] = 'application/json'
145
+
146
+ response = http_request(uri) { |http| http.request(request) }
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
+ # Normalize MIME structure for desktop email client compatibility
181
+ normalize_mail_for_display(mail).to_s
182
+ rescue StandardError => e
183
+ Rails.logger.warn("Resend: Failed to normalize email structure - #{e.message}")
184
+ mail.to_s # Fall back to original if normalization fails
185
+ end
186
+
187
+ # rubocop:disable Metrics/AbcSize
188
+ def set_mail_headers(mail, email_data)
189
+ mail.from = email_data['from']
190
+ mail.to = email_data['to']
191
+ mail.cc = email_data['cc'] if email_data['cc'].present?
192
+ mail.bcc = email_data['bcc'] if email_data['bcc'].present?
193
+ mail.subject = email_data['subject'] || '(no subject)'
194
+
195
+ # Add threading headers if available
196
+ mail.message_id = email_data['message_id'] if email_data['message_id'].present?
197
+ mail.in_reply_to = email_data.dig('headers', 'in-reply-to') if email_data.dig('headers', 'in-reply-to').present?
198
+ mail.references = email_data.dig('headers', 'references') if email_data.dig('headers', 'references').present?
199
+ end
200
+ # rubocop:enable Metrics/AbcSize
201
+
202
+ def set_mail_body(mail, email_data, _has_attachments)
203
+ if email_data['html'].present? && email_data['text'].present?
204
+ # Both text and HTML - create multipart/alternative
205
+ mail.text_part = create_text_part(email_data['text'])
206
+ mail.html_part = create_html_part(email_data['html'])
207
+ elsif email_data['html'].present?
208
+ # HTML only
209
+ mail.content_type = 'text/html; charset=UTF-8'
210
+ mail.body = email_data['html']
211
+ else
212
+ # Text only
213
+ mail.content_type = 'text/plain; charset=UTF-8'
214
+ mail.body = email_data['text'] || '(no body)'
215
+ end
216
+ end
217
+
218
+ def create_text_part(text_content)
219
+ Mail::Part.new do
220
+ content_type 'text/plain; charset=UTF-8'
221
+ body text_content
222
+ end
223
+ end
224
+
225
+ def create_html_part(html_content)
226
+ Mail::Part.new do
227
+ content_type 'text/html; charset=UTF-8'
228
+ body html_content
229
+ end
230
+ end
231
+
232
+ def add_all_attachments(mail, email_data)
233
+ total = email_data['attachments'].size
234
+ failed = 0
235
+
236
+ email_data['attachments'].each do |attachment_meta|
237
+ result = add_attachment_to_mail(mail, email_data['email_id'], attachment_meta)
238
+ failed += 1 if result.nil?
239
+ end
240
+
241
+ Rails.logger.info("Resend attachments: #{total - failed}/#{total} successful")
242
+ Rails.logger.warn("Resend: #{failed} attachments failed to download") if failed.positive?
243
+ end
244
+
245
+ def add_attachment_to_mail(mail, email_id, attachment_meta)
246
+ # Fetch attachment content from Resend API
247
+ attachment_content = fetch_attachment_from_resend(email_id, attachment_meta['id'])
248
+ return nil if attachment_content.nil?
249
+
250
+ # Handle inline vs regular attachments differently
251
+ # Inline attachments must use mail.attachments.inline[] to set Content-Disposition header
252
+ # This allows MailboxHelper to detect them via .inline? and convert cid: to ActiveStorage URLs
253
+ if attachment_meta['content_disposition'] == 'inline'
254
+ add_inline_attachment(mail, attachment_meta, attachment_content)
255
+ else
256
+ add_regular_attachment(mail, attachment_meta, attachment_content)
257
+ end
258
+
259
+ true # Return success
260
+ rescue StandardError => e
261
+ Rails.logger.error("Failed to add attachment #{attachment_meta['id']}: #{e.message}")
262
+ nil # Return failure
263
+ end
264
+
265
+ def add_inline_attachment(mail, attachment_meta, attachment_content)
266
+ mail.attachments.inline[attachment_meta['filename']] = {
267
+ content_type: attachment_meta['content_type'],
268
+ content: attachment_content
269
+ }
270
+
271
+ # Set Content-ID for cid: reference matching
272
+ return if attachment_meta['content_id'].blank?
273
+
274
+ # Strip angle brackets as Mail gem adds them automatically
275
+ # Resend provides: "<content-id>", Mail gem expects: "content-id"
276
+ content_id_without_brackets = attachment_meta['content_id'].gsub(/[<>]/, '')
277
+ mail.attachments.last.content_id = content_id_without_brackets
278
+ end
279
+
280
+ def add_regular_attachment(mail, attachment_meta, attachment_content)
281
+ mail.attachments[attachment_meta['filename']] = {
282
+ content_type: attachment_meta['content_type'],
283
+ content: attachment_content
284
+ }
285
+ end
286
+
287
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
288
+ def fetch_attachment_from_resend(email_id, attachment_id)
289
+ api_key = ENV.fetch('RESEND_API_KEY', nil)
290
+ return nil if api_key.blank?
291
+
292
+ # Step 1: Get attachment metadata including download_url
293
+ metadata_uri = URI("https://api.resend.com/emails/receiving/#{email_id}/attachments/#{attachment_id}")
294
+ metadata_request = Net::HTTP::Get.new(metadata_uri)
295
+ metadata_request['Authorization'] = "Bearer #{api_key}"
296
+
297
+ metadata_response = http_request(metadata_uri) { |http| http.request(metadata_request) }
298
+
299
+ unless metadata_response.is_a?(Net::HTTPSuccess)
300
+ Rails.logger.error("Resend API error fetching attachment metadata: #{metadata_response.code} - #{metadata_response.body}")
301
+ return nil
302
+ end
303
+
304
+ metadata = JSON.parse(metadata_response.body)
305
+ download_url = metadata['download_url']
306
+
307
+ if download_url.blank?
308
+ Rails.logger.error('No download_url in attachment metadata')
309
+ return nil
310
+ end
311
+
312
+ # Validate download URL to prevent SSRF attacks
313
+ unless valid_resend_url?(download_url)
314
+ Rails.logger.error("Invalid or unauthorized download URL: #{download_url}")
315
+ return nil
316
+ end
317
+
318
+ # Step 2: Download actual file content from the download_url
319
+ download_uri = URI(download_url)
320
+
321
+ # Check attachment size before downloading
322
+ head_response = http_request(download_uri) { |http| http.head(download_uri.request_uri) }
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 = http_request(download_uri) { |http| http.get(download_uri.request_uri) }
333
+
334
+ # Block redirects to prevent SSRF attacks
335
+ if download_response.is_a?(Net::HTTPRedirection)
336
+ Rails.logger.warn("Resend attachment download redirect blocked: #{download_url}")
337
+ return nil
338
+ end
339
+
340
+ return download_response.body if download_response.is_a?(Net::HTTPSuccess)
341
+
342
+ Rails.logger.error("Failed to download attachment from URL: #{download_response.code}")
343
+ nil
344
+ rescue StandardError => e
345
+ Rails.logger.error("Failed to fetch attachment from Resend: #{e.message}")
346
+ nil
347
+ end
348
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
349
+
350
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
351
+ def convert_data_uris_to_cid(html, attachments, email_id)
352
+ # Resend embeds inline images as data URIs in HTML
353
+ # We need to convert them to cid: references that match the attachment Content-IDs
354
+ # This allows MailboxHelper to convert them to ActiveStorage URLs
355
+
356
+ return html if html.blank? || attachments.blank?
357
+
358
+ # Find inline image attachments with content_ids
359
+ inline_attachments = attachments.select { |a| a['content_disposition'] == 'inline' && a['content_id'].present? }
360
+ return html unless inline_attachments.any?
361
+
362
+ # Build a map of base64-encoded image data to content_ids by fetching each attachment
363
+ data_uri_to_cid = build_data_uri_map(inline_attachments, email_id)
364
+
365
+ # Replace data URI images with cid: references
366
+ # Match: <img src="data:image/TYPE;base64,BASE64DATA" ...>
367
+ html.gsub(%r{(<img[^>]+src=")data:image/[^;]+;base64,([^"]+)("[^>]*>)}i) do |match|
368
+ prefix = ::Regexp.last_match(1)
369
+ base64_data = ::Regexp.last_match(2)
370
+ suffix = ::Regexp.last_match(3)
371
+
372
+ # Find matching content_id for this base64 data
373
+ content_id = data_uri_to_cid[base64_data]
374
+
375
+ if content_id
376
+ # Replace with cid: reference
377
+ "#{prefix}cid:#{content_id}#{suffix}"
378
+ else
379
+ # No matching attachment found, leave as data URI
380
+ match
381
+ end
382
+ end
383
+ rescue StandardError => e
384
+ Rails.logger.error("Failed to convert data URIs to cid references: #{e.message}")
385
+ html # Return original HTML on error
386
+ end
387
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
388
+
389
+ def build_data_uri_map(inline_attachments, email_id)
390
+ data_uri_to_cid = {}
391
+ inline_attachments.each do |attachment|
392
+ attachment_content = fetch_attachment_from_resend(email_id, attachment['id'])
393
+ next if attachment_content.nil?
394
+
395
+ # Encode as base64 to match data URI format
396
+ base64_content = Base64.strict_encode64(attachment_content)
397
+ # Strip angle brackets from content_id (Resend provides "<cid>", we need "cid")
398
+ content_id = attachment['content_id'].gsub(/[<>]/, '')
399
+ data_uri_to_cid[base64_content] = content_id
400
+ end
401
+ data_uri_to_cid
402
+ end
403
+
404
+ def valid_resend_url?(url)
405
+ return false if url.blank?
406
+
407
+ uri = URI.parse(url)
408
+
409
+ # Check scheme - only HTTPS allowed
410
+ return false unless uri.scheme == 'https'
411
+
412
+ # Check host against Resend domain pattern (*.resend.com)
413
+ return false unless uri.host&.match?(ALLOWED_HOST_PATTERN)
414
+
415
+ true
416
+ rescue URI::InvalidURIError => e
417
+ Rails.logger.error("Invalid URL in Resend webhook: #{url} - #{e.message}")
418
+ false
419
+ end
420
+
421
+ # Returns a cert store configured for broad compatibility.
422
+ # OpenSSL 3.x enables CRL checking by default, which fails for many CDNs
423
+ # (including Cloudflare, which fronts Resend's API). This disables CRL
424
+ # checking while maintaining certificate verification.
425
+ def default_cert_store
426
+ store = OpenSSL::X509::Store.new
427
+ store.set_default_paths
428
+ store.flags = 0 # Disable CRL checking for OpenSSL 3.x compatibility
429
+ store
430
+ end
431
+
432
+ def http_request(uri, open_timeout: 5, read_timeout: 10)
433
+ Net::HTTP.start(
434
+ uri.hostname,
435
+ uri.port,
436
+ use_ssl: true,
437
+ cert_store: default_cert_store,
438
+ open_timeout: open_timeout,
439
+ read_timeout: read_timeout
440
+ ) do |http|
441
+ yield http
442
+ end
443
+ end
444
+
445
+ # Normalize email MIME structure for desktop email client compatibility
446
+ # Restructures to: multipart/mixed -> multipart/related -> multipart/alternative
447
+ # This ensures .eml files open correctly in Apple Mail, Thunderbird, etc.
448
+ def normalize_mail_for_display(mail)
449
+ mixed = Mail.new
450
+ # Copy all headers from original mail to preserve custom headers
451
+ mail.header.fields.each do |field|
452
+ mixed.header[field.name] = field.value unless field.name == 'Content-Type'
453
+ end
454
+ mixed.content_type = 'multipart/mixed'
455
+
456
+ related = Mail::Part.new
457
+ related.content_type = 'multipart/related'
458
+
459
+ alt = Mail::Part.new
460
+ alt.content_type = 'multipart/alternative'
461
+ if mail.multipart?
462
+ alt.add_part(mail.text_part) if mail.text_part
463
+ alt.add_part(mail.html_part) if mail.html_part
464
+ else
465
+ # For non-multipart emails, create a single part with the body
466
+ content_type = mail.content_type.presence || 'text/plain; charset=UTF-8'
467
+ alt.add_part(Mail::Part.new do
468
+ content_type content_type
469
+ body mail.body.decoded
470
+ end)
471
+ end
472
+ related.add_part(alt)
473
+
474
+ mail.attachments.each do |att|
475
+ if att.inline?
476
+ # Use attachments.inline[] to preserve Content-Disposition: inline
477
+ related.attachments.inline[att.filename] = {
478
+ content_type: att.content_type,
479
+ content: att.body.decoded
480
+ }
481
+ # Preserve Content-ID
482
+ related.attachments.last.content_id = att.cid if att.cid.present?
483
+ else
484
+ mixed.attachments[att.filename] = {
485
+ content_type: att.content_type,
486
+ content: att.body.decoded
487
+ }
488
+ end
489
+ end
490
+
491
+ mixed.add_part(related)
492
+ mixed
493
+ end
494
+ end
495
+ end
496
+ end
497
+ # 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.5"
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.5
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.6.9
107
+ specification_version: 4
108
+ summary: Resend email ingress for ActionMailbox
109
+ test_files: []