resend_robot 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8b7d0f765f0c9920e99d5cd92d614d1a4557adc36907c411ac1e27e919b4ff47
4
+ data.tar.gz: b1dd5181eb64bb2c13a35b14c15f2589e31a08a0c65fcf562f6ae7f8d8ded38c
5
+ SHA512:
6
+ metadata.gz: 594c4e376576f217f51b226a16447a22f70cdaaa79ea478728fddd219ec862c4fcf73e990f0ed6fcec9c65ba8bcf112783cf6e3e32530a73daf6aebb7a6dd28e
7
+ data.tar.gz: ac17dcfc04c8dab6567de3a3bc44c97bf6d0ea2550615b9019dcf9afe9bbcd9bf2c5501c128a569d76e7ca6784dae5e508e598a2961ec2faecf77ff4bdd971a6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Garry Tan
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Resend Robot
2
+
3
+ **letter_opener for the [Resend](https://resend.com) gem.**
4
+
5
+ Intercepts Resend API calls in development, stores emails as JSON on disk, and provides a web UI to browse, preview, and simulate replies. Production code runs unchanged — same pipeline in dev and prod.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "resend_robot", group: :development
13
+ ```
14
+
15
+ Run `bundle install`, then run the install generator:
16
+
17
+ ```bash
18
+ bin/rails generate resend_robot:install
19
+ ```
20
+
21
+ This creates:
22
+ - `config/initializers/resend_robot.rb` — configuration (reply domain, mount path, etc.)
23
+ - `.claude/skills/resend-robot-read/skill.md` — Claude skill for reading dev emails
24
+ - `.claude/skills/resend-robot-send/skill.md` — Claude skill for simulating inbound emails
25
+
26
+ The generator is safe to re-run — it will ask before overwriting existing files.
27
+
28
+ Resend Robot automatically:
29
+ - Installs shims that intercept `Resend::Emails.send`, `Resend::Batch.send`, etc.
30
+ - Sets a dummy API key so `Resend::Mailer` doesn't raise
31
+ - Injects routes at `/dev/email` for the web UI
32
+ - Provides rake tasks for CLI interaction
33
+
34
+ ## Web UI
35
+
36
+ Visit `http://localhost:3000/dev/email` to browse your dev mailbox:
37
+
38
+ - **Outbox** — all intercepted emails, newest first, with search/filter
39
+ - **Preview** — inline HTML rendering with sanitization (no iframes)
40
+ - **Reply** — simulate inbound email through your real webhook pipeline
41
+
42
+ ## Configuration
43
+
44
+ ```ruby
45
+ # config/initializers/resend_robot.rb
46
+ ResendRobot.configure do |config|
47
+ config.reply_domain = "reply.example.com" # required for inbound simulation
48
+ config.webhook_path = "/webhooks/resend" # default
49
+ config.dev_port = 3000 # auto-detected from action_mailer
50
+ config.open_in_browser = true # auto-open emails in browser
51
+ config.mount_path = "/dev/email" # change to mount UI elsewhere
52
+ end
53
+ ```
54
+
55
+ ### Bypass the shim
56
+
57
+ To send real emails via the Resend API in development:
58
+
59
+ ```bash
60
+ RESEND_IN_DEV=1 bin/dev
61
+ ```
62
+
63
+ ### Suppress auto-open
64
+
65
+ ```bash
66
+ OPEN_EMAILS=0 bin/dev
67
+ ```
68
+
69
+ ## Test Helpers
70
+
71
+ Resend Robot includes Minitest assertions for verifying emails in your test suite:
72
+
73
+ ```ruby
74
+ class UserSignupTest < ActiveSupport::TestCase
75
+ include ResendRobot::TestHelper
76
+
77
+ test "sends welcome email on signup" do
78
+ clear_emails
79
+ User.create!(email: "alice@example.com")
80
+
81
+ assert_email_sent_to "alice@example.com"
82
+ assert_email_sent_to "alice@example.com", subject: /Welcome/
83
+ assert_equal 1, emails_sent.count
84
+ assert_equal "Welcome", last_email[:subject]
85
+ end
86
+
87
+ test "does not send email without signup" do
88
+ clear_emails
89
+ assert_no_emails_sent
90
+ end
91
+ end
92
+ ```
93
+
94
+ ### Available assertions
95
+
96
+ | Method | Description |
97
+ |--------|-------------|
98
+ | `clear_emails` | Clear all stored emails (call in setup) |
99
+ | `emails_sent` | All outbound emails as symbol-keyed hashes |
100
+ | `last_email` | Most recent outbound email |
101
+ | `assert_email_sent_to(addr, subject: nil)` | Assert email was sent to address, optionally matching subject (String or Regexp) |
102
+ | `assert_no_emails_sent` | Assert no emails were sent |
103
+
104
+ ## JSON API
105
+
106
+ For E2E tests (Playwright, Capybara), the web UI also serves JSON:
107
+
108
+ ```
109
+ GET /dev/email.json → array of all emails
110
+ GET /dev/email/:id.json → single email object
111
+ ```
112
+
113
+ ## Claude Skills
114
+
115
+ The install generator adds two [Claude Code](https://claude.ai/claude-code) skills:
116
+
117
+ | Skill | Description |
118
+ |-------|-------------|
119
+ | `/resend-robot-read` | List, search, and inspect dev emails from the CLI |
120
+ | `/resend-robot-send` | Simulate inbound emails through your webhook pipeline |
121
+
122
+ These let Claude interact with your dev mailbox conversationally:
123
+
124
+ - "Show me the last email sent" → runs `/resend-robot-read`
125
+ - "Simulate a reply to the welcome email" → runs `/resend-robot-send`
126
+
127
+ Skills are plain markdown files in `.claude/skills/` — customize them for your app (e.g., add app-specific models or webhook details).
128
+
129
+ ## Rake Tasks
130
+
131
+ ```bash
132
+ bin/rails resend_robot:outbox # List recent emails
133
+ bin/rails resend_robot:show[rl_xxx] # Show specific email
134
+ bin/rails resend_robot:receive[from,to,subject,body] # Simulate inbound
135
+ bin/rails resend_robot:reply[index,body] # Reply to Nth most recent
136
+ bin/rails resend_robot:clear # Clear all emails
137
+ ```
138
+
139
+ ## How It Works
140
+
141
+ Resend Robot monkey-patches 5 Resend gem methods in development:
142
+
143
+ | Method | Shimmed behavior |
144
+ |--------|-----------------|
145
+ | `Resend::Emails.send` | Stores email as JSON on disk |
146
+ | `Resend::Batch.send` | Stores each email individually |
147
+ | `Resend::Emails::Receiving.get` | Returns stored inbound email |
148
+ | `Resend::Emails::Receiving::Attachments.get` | Returns stub attachment |
149
+ | `Resend::Webhooks.verify` | Returns `true` (bypasses signature validation) |
150
+
151
+ Your production code (mailers, webhook controllers, inbound email handlers) runs unchanged. The shim operates at the Resend gem boundary — everything downstream is real.
152
+
153
+ ## vs letter_opener
154
+
155
+ | | letter_opener | Resend Robot |
156
+ |-|---------------|--------------|
157
+ | Works with | SMTP delivery | Resend API client |
158
+ | Storage | Rendered HTML files | JSON (preserves all metadata) |
159
+ | Inbound simulation | No | Yes — POSTs to your real webhook endpoint |
160
+ | Test helpers | No | Yes (`assert_email_sent_to`, etc.) |
161
+ | JSON API | No | Yes (for E2E tests) |
162
+ | Web UI | Basic | Search, filter, reply simulation |
163
+
164
+ ## Requirements
165
+
166
+ - Ruby >= 3.1
167
+ - Rails >= 7.0
168
+ - [resend](https://github.com/resendlabs/resend-ruby) gem >= 1.0
169
+
170
+ ## Documentation
171
+
172
+ - [ARCHITECTURE.md](ARCHITECTURE.md) — System design, component overview, data flow diagrams
173
+ - [CONTRIBUTING.md](CONTRIBUTING.md) — Dev setup, running tests, project structure
174
+ - [CHANGELOG.md](CHANGELOG.md) — Release history
175
+ - [TODOS.md](TODOS.md) — Planned future work
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Dev-only email UI — no auth required.
4
+ # Browse outbound emails, preview HTML, simulate replies through the real
5
+ # webhook pipeline. Inherits from ActionController::Base to avoid
6
+ # host-app before_actions (auth, etc.).
7
+ #
8
+ # rubocop:disable Rails/ApplicationController
9
+ module ResendRobot
10
+ class MailboxController < ActionController::Base
11
+ layout false
12
+
13
+ before_action :require_local!
14
+ before_action :set_email, only: %i[show reply]
15
+
16
+ # GET /dev/email
17
+ def index
18
+ @emails = ResendRobot.list_outbound(limit: 50)
19
+ respond_to do |format|
20
+ format.html
21
+ format.json { render json: @emails }
22
+ end
23
+ end
24
+
25
+ # GET /dev/email/:id
26
+ def show
27
+ head :not_found and return unless @email
28
+
29
+ @email_html = ResendRobot::HtmlSanitizer.sanitize(@email[:html]) if @email[:html].present?
30
+
31
+ respond_to do |format|
32
+ format.html
33
+ format.json { render json: @email }
34
+ end
35
+ end
36
+
37
+ # POST /dev/email/:id/reply
38
+ def reply
39
+ head :not_found and return unless @email
40
+
41
+ body = params[:body].presence || 'Simulated reply from Resend Robot'
42
+ ResendRobot.simulate_inbound(
43
+ from: Array(@email[:to]).first,
44
+ to: Array(@email[:reply_to]).first,
45
+ subject: "Re: #{@email[:subject]}",
46
+ body: body
47
+ )
48
+
49
+ redirect_to main_app.dev_email_preview_path(@email[:id]),
50
+ notice: 'Reply simulated — check InboundEmailHandler logs'
51
+ rescue StandardError => e
52
+ redirect_to main_app.dev_email_preview_path(@email[:id]), alert: "Reply failed: #{e.message}"
53
+ end
54
+
55
+ # DELETE /dev/email/clear
56
+ def clear
57
+ ResendRobot.clear!
58
+ redirect_to main_app.dev_email_index_path, notice: 'Resend Robot mailbox cleared'
59
+ end
60
+
61
+ private
62
+
63
+ def require_local!
64
+ head :forbidden unless Rails.env.local?
65
+ end
66
+
67
+ def set_email
68
+ @email = ResendRobot.find_outbound(params[:id])
69
+ end
70
+
71
+ end
72
+ end
73
+ # rubocop:enable Rails/ApplicationController
@@ -0,0 +1,269 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Resend Robot — Dev Mailbox</title>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&family=DM+Sans:wght@400;500;600&display=swap');
9
+
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+
12
+ body {
13
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
14
+ background: #f8f6f3;
15
+ color: #334155;
16
+ min-height: 100vh;
17
+ }
18
+
19
+ a { color: #334155; text-decoration: none; }
20
+ a:hover { text-decoration: underline; }
21
+
22
+ /* ── Top identity bar ── */
23
+ .identity-bar {
24
+ background: #1e293b;
25
+ padding: 10px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ gap: 10px;
29
+ border-bottom: 2px solid #be123c;
30
+ }
31
+ .identity-bar .logo {
32
+ font-family: 'Source Serif 4', Georgia, serif;
33
+ font-weight: 600;
34
+ font-size: 15px;
35
+ color: #fbbf24;
36
+ letter-spacing: -0.01em;
37
+ }
38
+ .identity-bar .logo:hover { text-decoration: none; color: #fde68a; }
39
+ .identity-bar .env-badge {
40
+ font-size: 10px;
41
+ font-weight: 600;
42
+ text-transform: uppercase;
43
+ letter-spacing: 0.08em;
44
+ color: #1e293b;
45
+ background: #fbbf24;
46
+ padding: 2px 7px;
47
+ border-radius: 3px;
48
+ }
49
+ .identity-bar .count {
50
+ margin-left: auto;
51
+ font-size: 12px;
52
+ color: #94a3b8;
53
+ }
54
+
55
+ /* ── Flash messages ── */
56
+ .flash {
57
+ padding: 10px 24px;
58
+ font-size: 13px;
59
+ font-weight: 500;
60
+ }
61
+ .flash-notice { background: #ecfdf5; color: #065f46; border-bottom: 1px solid #a7f3d0; }
62
+
63
+ /* ── Content ── */
64
+ .content {
65
+ max-width: 860px;
66
+ margin: 0 auto;
67
+ padding: 20px 24px 40px;
68
+ }
69
+ .toolbar {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ margin-bottom: 16px;
74
+ }
75
+ .toolbar h1 {
76
+ font-family: 'Source Serif 4', Georgia, serif;
77
+ font-size: 20px;
78
+ font-weight: 600;
79
+ color: #1e293b;
80
+ }
81
+ .btn-clear {
82
+ background: #fff;
83
+ color: #64748b;
84
+ border: 1px solid #d1d5db;
85
+ padding: 6px 14px;
86
+ border-radius: 5px;
87
+ font-size: 12px;
88
+ font-weight: 500;
89
+ cursor: pointer;
90
+ font-family: inherit;
91
+ }
92
+ .btn-clear:hover { background: #f1f5f9; color: #334155; }
93
+
94
+ /* ── Search ── */
95
+ .search-bar {
96
+ margin-bottom: 12px;
97
+ }
98
+ .search-bar input {
99
+ width: 100%;
100
+ padding: 8px 12px;
101
+ border: 1px solid #d1d5db;
102
+ border-radius: 5px;
103
+ font-size: 13px;
104
+ font-family: inherit;
105
+ background: #fff;
106
+ color: #334155;
107
+ }
108
+ .search-bar input:focus {
109
+ outline: none;
110
+ border-color: #be123c;
111
+ box-shadow: 0 0 0 2px rgba(190,18,60,0.1);
112
+ }
113
+ .search-bar input::placeholder { color: #94a3b8; }
114
+
115
+ /* ── Email list ── */
116
+ .email-list {
117
+ background: #fff;
118
+ border: 1px solid #e2e0db;
119
+ border-radius: 6px;
120
+ overflow: hidden;
121
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06);
122
+ }
123
+ .email-row {
124
+ display: grid;
125
+ grid-template-columns: 1fr auto;
126
+ gap: 8px;
127
+ padding: 12px 16px;
128
+ border-bottom: 1px solid #f1f0ed;
129
+ transition: background 0.1s;
130
+ cursor: pointer;
131
+ }
132
+ .email-row:last-child { border-bottom: none; }
133
+ .email-row:hover { background: #fefdfb; }
134
+ .email-row .subject {
135
+ font-weight: 500;
136
+ font-size: 14px;
137
+ color: #1e293b;
138
+ white-space: nowrap;
139
+ overflow: hidden;
140
+ text-overflow: ellipsis;
141
+ }
142
+ .email-row .to-line {
143
+ font-size: 12px;
144
+ color: #64748b;
145
+ margin-top: 2px;
146
+ }
147
+ .email-row .to-line .addr {
148
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
149
+ font-size: 11px;
150
+ background: #f1f5f9;
151
+ padding: 0 5px;
152
+ border-radius: 3px;
153
+ }
154
+ .email-row .reply-to-hint {
155
+ font-size: 11px;
156
+ color: #94a3b8;
157
+ margin-top: 2px;
158
+ }
159
+ .email-row .time {
160
+ font-size: 12px;
161
+ color: #94a3b8;
162
+ white-space: nowrap;
163
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
164
+ align-self: start;
165
+ padding-top: 2px;
166
+ }
167
+
168
+ /* ── Empty state ── */
169
+ .empty-state {
170
+ text-align: center;
171
+ padding: 48px 24px;
172
+ }
173
+ .empty-state p {
174
+ color: #94a3b8;
175
+ font-size: 14px;
176
+ }
177
+ .empty-state .hint {
178
+ font-size: 12px;
179
+ margin-top: 8px;
180
+ }
181
+
182
+ .hidden { display: none !important; }
183
+
184
+ @media (max-width: 640px) {
185
+ .content { padding: 12px; }
186
+ .email-row { grid-template-columns: 1fr; }
187
+ .email-row .time { font-size: 11px; }
188
+ }
189
+ </style>
190
+ </head>
191
+ <body>
192
+ <div class="identity-bar">
193
+ <span class="logo">Resend Robot</span>
194
+ <span class="env-badge">Dev</span>
195
+ <span class="count"><%= @emails.size %> email<%= @emails.size == 1 ? '' : 's' %></span>
196
+ </div>
197
+
198
+ <% if flash[:notice] %>
199
+ <div class="flash flash-notice"><%= flash[:notice] %></div>
200
+ <% end %>
201
+
202
+ <div class="content">
203
+ <div class="toolbar">
204
+ <h1>Outbox</h1>
205
+ <% if @emails.any? %>
206
+ <form action="<%= main_app.dev_email_clear_path %>" method="post" onsubmit="return confirm('Clear all stored emails?')">
207
+ <input type="hidden" name="_method" value="delete">
208
+ <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
209
+ <button type="submit" class="btn-clear">Clear All</button>
210
+ </form>
211
+ <% end %>
212
+ </div>
213
+
214
+ <% if @emails.any? %>
215
+ <div class="search-bar">
216
+ <input type="text" id="email-search" placeholder="Filter by subject, to, or from..." autocomplete="off">
217
+ </div>
218
+ <% end %>
219
+
220
+ <% if @emails.empty? %>
221
+ <div class="email-list">
222
+ <div class="empty-state">
223
+ <p>No emails yet</p>
224
+ <p class="hint">Emails sent via ActionMailer are intercepted by Resend Robot and stored locally.</p>
225
+ </div>
226
+ </div>
227
+ <% else %>
228
+ <div class="email-list" id="email-list">
229
+ <% @emails.each do |email| %>
230
+ <a href="<%= main_app.dev_email_preview_path(email[:id]) %>" class="email-row"
231
+ data-subject="<%= email[:subject] %>"
232
+ data-to="<%= Array(email[:to]).join(' ') %>"
233
+ data-from="<%= email[:from] %>">
234
+ <div>
235
+ <div class="subject"><%= email[:subject] %></div>
236
+ <div class="to-line">
237
+ To: <% Array(email[:to]).each do |addr| %><span class="addr"><%= addr %></span> <% end %>
238
+ </div>
239
+ <% if Array(email[:reply_to]).any? %>
240
+ <div class="reply-to-hint">Reply-To: <%= Array(email[:reply_to]).first %></div>
241
+ <% end %>
242
+ </div>
243
+ <div class="time"><%= Time.parse(email[:sent_at]).strftime("%H:%M:%S") rescue email[:sent_at] %></div>
244
+ </a>
245
+ <% end %>
246
+ </div>
247
+ <% end %>
248
+ </div>
249
+
250
+ <script>
251
+ document.addEventListener('DOMContentLoaded', function() {
252
+ var search = document.getElementById('email-search');
253
+ if (!search) return;
254
+
255
+ search.addEventListener('input', function() {
256
+ var query = this.value.toLowerCase();
257
+ var rows = document.querySelectorAll('#email-list .email-row');
258
+ rows.forEach(function(row) {
259
+ var subject = (row.getAttribute('data-subject') || '').toLowerCase();
260
+ var to = (row.getAttribute('data-to') || '').toLowerCase();
261
+ var from = (row.getAttribute('data-from') || '').toLowerCase();
262
+ var match = !query || subject.indexOf(query) !== -1 || to.indexOf(query) !== -1 || from.indexOf(query) !== -1;
263
+ row.classList.toggle('hidden', !match);
264
+ });
265
+ });
266
+ });
267
+ </script>
268
+ </body>
269
+ </html>