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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/app/controllers/resend_robot/mailbox_controller.rb +73 -0
- data/app/views/resend_robot/mailbox/index.html.erb +269 -0
- data/app/views/resend_robot/mailbox/show.html.erb +392 -0
- data/lib/generators/resend_robot/install_generator.rb +35 -0
- data/lib/generators/resend_robot/templates/initializer.rb +29 -0
- data/lib/generators/resend_robot/templates/skills/resend-robot-read/skill.md +64 -0
- data/lib/generators/resend_robot/templates/skills/resend-robot-send/skill.md +77 -0
- data/lib/resend_robot/configuration.rb +50 -0
- data/lib/resend_robot/engine.rb +47 -0
- data/lib/resend_robot/html_sanitizer.rb +33 -0
- data/lib/resend_robot/shim.rb +72 -0
- data/lib/resend_robot/storage.rb +231 -0
- data/lib/resend_robot/tasks/resend_robot.rake +78 -0
- data/lib/resend_robot/test_helper.rb +61 -0
- data/lib/resend_robot/version.rb +5 -0
- data/lib/resend_robot.rb +75 -0
- metadata +88 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= @email[:subject] %> — Resend Robot</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
|
+
display: flex;
|
|
18
|
+
flex-direction: column;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
a { color: #334155; text-decoration: none; }
|
|
22
|
+
a:hover { text-decoration: underline; }
|
|
23
|
+
|
|
24
|
+
/* ── Top identity bar ── */
|
|
25
|
+
.identity-bar {
|
|
26
|
+
background: #1e293b;
|
|
27
|
+
padding: 10px 24px;
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: center;
|
|
30
|
+
gap: 10px;
|
|
31
|
+
border-bottom: 2px solid #be123c;
|
|
32
|
+
}
|
|
33
|
+
.identity-bar .logo {
|
|
34
|
+
font-family: 'Source Serif 4', Georgia, serif;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
font-size: 15px;
|
|
37
|
+
color: #fbbf24;
|
|
38
|
+
letter-spacing: -0.01em;
|
|
39
|
+
}
|
|
40
|
+
.identity-bar .logo:hover { text-decoration: none; color: #fde68a; }
|
|
41
|
+
.identity-bar .env-badge {
|
|
42
|
+
font-size: 10px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
text-transform: uppercase;
|
|
45
|
+
letter-spacing: 0.08em;
|
|
46
|
+
color: #1e293b;
|
|
47
|
+
background: #fbbf24;
|
|
48
|
+
padding: 2px 7px;
|
|
49
|
+
border-radius: 3px;
|
|
50
|
+
}
|
|
51
|
+
.identity-bar .all-link {
|
|
52
|
+
font-size: 12px;
|
|
53
|
+
font-weight: 500;
|
|
54
|
+
color: #94a3b8;
|
|
55
|
+
background: rgba(255,255,255,0.08);
|
|
56
|
+
padding: 4px 10px;
|
|
57
|
+
border-radius: 4px;
|
|
58
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
59
|
+
}
|
|
60
|
+
.identity-bar .all-link:hover { color: #fff; background: rgba(255,255,255,0.15); text-decoration: none; }
|
|
61
|
+
.identity-bar .id-text {
|
|
62
|
+
margin-left: auto;
|
|
63
|
+
font-size: 12px;
|
|
64
|
+
color: #64748b;
|
|
65
|
+
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ── Flash messages ── */
|
|
69
|
+
.flash {
|
|
70
|
+
padding: 10px 24px;
|
|
71
|
+
font-size: 13px;
|
|
72
|
+
font-weight: 500;
|
|
73
|
+
}
|
|
74
|
+
.flash-notice { background: #ecfdf5; color: #065f46; border-bottom: 1px solid #a7f3d0; }
|
|
75
|
+
.flash-alert { background: #fef2f2; color: #991b1b; border-bottom: 1px solid #fecaca; }
|
|
76
|
+
|
|
77
|
+
/* ── Metadata panel ── */
|
|
78
|
+
.metadata {
|
|
79
|
+
background: #fff;
|
|
80
|
+
border-bottom: 1px solid #e2e0db;
|
|
81
|
+
padding: 20px 24px;
|
|
82
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
83
|
+
}
|
|
84
|
+
.subject-line {
|
|
85
|
+
font-family: 'Source Serif 4', Georgia, serif;
|
|
86
|
+
font-size: 22px;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
color: #1e293b;
|
|
89
|
+
margin-bottom: 16px;
|
|
90
|
+
line-height: 1.3;
|
|
91
|
+
}
|
|
92
|
+
.fields {
|
|
93
|
+
display: grid;
|
|
94
|
+
grid-template-columns: auto 1fr;
|
|
95
|
+
gap: 6px 16px;
|
|
96
|
+
align-items: baseline;
|
|
97
|
+
font-size: 13px;
|
|
98
|
+
}
|
|
99
|
+
.field-label {
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
color: #94a3b8;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
font-size: 10px;
|
|
104
|
+
letter-spacing: 0.06em;
|
|
105
|
+
padding-top: 2px;
|
|
106
|
+
text-align: left;
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
}
|
|
109
|
+
.field-value {
|
|
110
|
+
color: #334155;
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
word-break: break-all;
|
|
114
|
+
}
|
|
115
|
+
.field-value .addr {
|
|
116
|
+
display: inline-block;
|
|
117
|
+
background: #f1f5f9;
|
|
118
|
+
padding: 1px 8px;
|
|
119
|
+
border-radius: 4px;
|
|
120
|
+
margin: 1px 2px;
|
|
121
|
+
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
}
|
|
124
|
+
.field-value .tag {
|
|
125
|
+
display: inline-block;
|
|
126
|
+
background: #fff1f2;
|
|
127
|
+
color: #9f1239;
|
|
128
|
+
padding: 1px 8px;
|
|
129
|
+
border-radius: 4px;
|
|
130
|
+
margin: 1px 2px;
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
font-weight: 500;
|
|
133
|
+
}
|
|
134
|
+
.field-value .timestamp { color: #64748b; }
|
|
135
|
+
|
|
136
|
+
.divider {
|
|
137
|
+
grid-column: 1 / -1;
|
|
138
|
+
height: 1px;
|
|
139
|
+
background: #f1f0ed;
|
|
140
|
+
margin: 4px 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── Two-column layout: body + sidebar ── */
|
|
144
|
+
.content-layout {
|
|
145
|
+
display: flex;
|
|
146
|
+
gap: 24px;
|
|
147
|
+
flex: 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── Email body — inline rendered ── */
|
|
151
|
+
.body-section {
|
|
152
|
+
flex: 1;
|
|
153
|
+
min-width: 0;
|
|
154
|
+
border-top: 1px solid #e2e0db;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
}
|
|
157
|
+
.email-body-content {
|
|
158
|
+
background: #fff;
|
|
159
|
+
padding: 24px;
|
|
160
|
+
}
|
|
161
|
+
.body-section pre {
|
|
162
|
+
padding: 24px;
|
|
163
|
+
white-space: pre-wrap;
|
|
164
|
+
word-wrap: break-word;
|
|
165
|
+
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
line-height: 1.7;
|
|
168
|
+
color: #334155;
|
|
169
|
+
}
|
|
170
|
+
.body-section .empty {
|
|
171
|
+
padding: 40px 24px;
|
|
172
|
+
text-align: center;
|
|
173
|
+
color: #94a3b8;
|
|
174
|
+
font-style: italic;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ── Sidebar (reply) ── */
|
|
178
|
+
.sidebar {
|
|
179
|
+
width: 280px;
|
|
180
|
+
flex-shrink: 0;
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
gap: 12px;
|
|
184
|
+
padding-top: 20px;
|
|
185
|
+
}
|
|
186
|
+
.reply-card {
|
|
187
|
+
background: #fff;
|
|
188
|
+
border: 1px solid #e2e0db;
|
|
189
|
+
border-radius: 6px;
|
|
190
|
+
padding: 16px;
|
|
191
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
192
|
+
}
|
|
193
|
+
.reply-card h3 {
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
color: #1e293b;
|
|
197
|
+
margin-bottom: 4px;
|
|
198
|
+
}
|
|
199
|
+
.reply-card .reply-hint {
|
|
200
|
+
font-size: 12px;
|
|
201
|
+
color: #94a3b8;
|
|
202
|
+
margin-bottom: 12px;
|
|
203
|
+
}
|
|
204
|
+
.reply-card .reply-hint strong {
|
|
205
|
+
color: #64748b;
|
|
206
|
+
font-family: ui-monospace, 'SF Mono', Monaco, monospace;
|
|
207
|
+
font-size: 11px;
|
|
208
|
+
}
|
|
209
|
+
.reply-card textarea {
|
|
210
|
+
width: 100%;
|
|
211
|
+
padding: 10px 12px;
|
|
212
|
+
border: 1px solid #d1d5db;
|
|
213
|
+
border-radius: 5px;
|
|
214
|
+
font-family: inherit;
|
|
215
|
+
font-size: 13px;
|
|
216
|
+
line-height: 1.5;
|
|
217
|
+
resize: vertical;
|
|
218
|
+
background: #fff;
|
|
219
|
+
color: #334155;
|
|
220
|
+
}
|
|
221
|
+
.reply-card textarea:focus {
|
|
222
|
+
outline: none;
|
|
223
|
+
border-color: #be123c;
|
|
224
|
+
box-shadow: 0 0 0 2px rgba(190,18,60,0.1);
|
|
225
|
+
}
|
|
226
|
+
.reply-actions {
|
|
227
|
+
display: flex;
|
|
228
|
+
gap: 8px;
|
|
229
|
+
margin-top: 10px;
|
|
230
|
+
}
|
|
231
|
+
.btn-reply {
|
|
232
|
+
background: #be123c;
|
|
233
|
+
color: #fff;
|
|
234
|
+
border: none;
|
|
235
|
+
padding: 7px 16px;
|
|
236
|
+
border-radius: 5px;
|
|
237
|
+
font-size: 13px;
|
|
238
|
+
font-weight: 500;
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
font-family: inherit;
|
|
241
|
+
}
|
|
242
|
+
.btn-reply:hover { background: #9f1239; }
|
|
243
|
+
.btn-reply:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
244
|
+
.no-reply-hint {
|
|
245
|
+
font-size: 12px;
|
|
246
|
+
color: #94a3b8;
|
|
247
|
+
font-style: italic;
|
|
248
|
+
padding: 8px 0;
|
|
249
|
+
}
|
|
250
|
+
/* ── Responsive ── */
|
|
251
|
+
@media (max-width: 768px) {
|
|
252
|
+
.metadata { padding: 16px; }
|
|
253
|
+
.subject-line { font-size: 18px; }
|
|
254
|
+
.fields { grid-template-columns: 1fr; gap: 2px 0; }
|
|
255
|
+
.content-layout { flex-direction: column; gap: 0; }
|
|
256
|
+
.sidebar { width: 100%; padding: 12px; }
|
|
257
|
+
}
|
|
258
|
+
</style>
|
|
259
|
+
</head>
|
|
260
|
+
<body>
|
|
261
|
+
<%# ── Identity bar ── %>
|
|
262
|
+
<div class="identity-bar">
|
|
263
|
+
<a href="<%= main_app.dev_email_index_path %>" class="logo">Resend Robot</a>
|
|
264
|
+
<span class="env-badge">Dev</span>
|
|
265
|
+
<a href="<%= main_app.dev_email_index_path %>" class="all-link">← All Emails</a>
|
|
266
|
+
<span class="id-text"><%= @email[:id] %></span>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<%# ── Flash messages ── %>
|
|
270
|
+
<% if flash[:notice] %>
|
|
271
|
+
<div class="flash flash-notice"><%= flash[:notice] %></div>
|
|
272
|
+
<% end %>
|
|
273
|
+
<% if flash[:alert] %>
|
|
274
|
+
<div class="flash flash-alert"><%= flash[:alert] %></div>
|
|
275
|
+
<% end %>
|
|
276
|
+
|
|
277
|
+
<%# ── Metadata panel ── %>
|
|
278
|
+
<div class="metadata">
|
|
279
|
+
<div class="subject-line"><%= @email[:subject] %></div>
|
|
280
|
+
<div class="fields">
|
|
281
|
+
<div class="field-label">From</div>
|
|
282
|
+
<div class="field-value"><span class="addr"><%= @email[:from] %></span></div>
|
|
283
|
+
|
|
284
|
+
<div class="field-label">To</div>
|
|
285
|
+
<div class="field-value">
|
|
286
|
+
<% Array(@email[:to]).each do |addr| %>
|
|
287
|
+
<span class="addr"><%= addr %></span>
|
|
288
|
+
<% end %>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<% if Array(@email[:cc]).any? %>
|
|
292
|
+
<div class="field-label">CC</div>
|
|
293
|
+
<div class="field-value">
|
|
294
|
+
<% Array(@email[:cc]).each do |addr| %>
|
|
295
|
+
<span class="addr"><%= addr %></span>
|
|
296
|
+
<% end %>
|
|
297
|
+
</div>
|
|
298
|
+
<% end %>
|
|
299
|
+
|
|
300
|
+
<% if Array(@email[:bcc]).any? %>
|
|
301
|
+
<div class="field-label">BCC</div>
|
|
302
|
+
<div class="field-value">
|
|
303
|
+
<% Array(@email[:bcc]).each do |addr| %>
|
|
304
|
+
<span class="addr"><%= addr %></span>
|
|
305
|
+
<% end %>
|
|
306
|
+
</div>
|
|
307
|
+
<% end %>
|
|
308
|
+
|
|
309
|
+
<% if Array(@email[:reply_to]).any? %>
|
|
310
|
+
<div class="field-label">Reply-To</div>
|
|
311
|
+
<div class="field-value">
|
|
312
|
+
<% Array(@email[:reply_to]).each do |addr| %>
|
|
313
|
+
<span class="addr"><%= addr %></span>
|
|
314
|
+
<% end %>
|
|
315
|
+
</div>
|
|
316
|
+
<% end %>
|
|
317
|
+
|
|
318
|
+
<div class="divider"></div>
|
|
319
|
+
|
|
320
|
+
<div class="field-label">Date</div>
|
|
321
|
+
<div class="field-value">
|
|
322
|
+
<span class="timestamp"><%= Time.parse(@email[:sent_at]).strftime("%B %-d, %Y at %-I:%M %p") rescue @email[:sent_at] %></span>
|
|
323
|
+
</div>
|
|
324
|
+
|
|
325
|
+
<% if Array(@email[:tags]).any? %>
|
|
326
|
+
<div class="field-label">Tags</div>
|
|
327
|
+
<div class="field-value">
|
|
328
|
+
<% Array(@email[:tags]).each do |t| %>
|
|
329
|
+
<span class="tag"><%= t[:name] %>: <%= t[:value] %></span>
|
|
330
|
+
<% end %>
|
|
331
|
+
</div>
|
|
332
|
+
<% end %>
|
|
333
|
+
|
|
334
|
+
<% if @email[:headers].present? %>
|
|
335
|
+
<div class="field-label">Headers</div>
|
|
336
|
+
<div class="field-value">
|
|
337
|
+
<% @email[:headers].each do |key, value| %>
|
|
338
|
+
<span class="tag"><%= key %>: <%= value %></span>
|
|
339
|
+
<% end %>
|
|
340
|
+
</div>
|
|
341
|
+
<% end %>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<%# ── Two-column layout: body + sidebar ── %>
|
|
346
|
+
<div class="content-layout">
|
|
347
|
+
<div class="body-section">
|
|
348
|
+
<% if @email_html.present? %>
|
|
349
|
+
<div class="email-body-content"><%= @email_html.html_safe %></div>
|
|
350
|
+
<% elsif @email[:text].present? %>
|
|
351
|
+
<pre><%= @email[:text] %></pre>
|
|
352
|
+
<% else %>
|
|
353
|
+
<div class="empty">No email body</div>
|
|
354
|
+
<% end %>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div class="sidebar">
|
|
358
|
+
<div class="reply-card">
|
|
359
|
+
<h3>Simulate Reply</h3>
|
|
360
|
+
<% reply_to = Array(@email[:reply_to]).first %>
|
|
361
|
+
<% if reply_to.present? %>
|
|
362
|
+
<div class="reply-hint">
|
|
363
|
+
Sends through the real webhook pipeline.
|
|
364
|
+
Reply-to: <strong><%= reply_to %></strong>
|
|
365
|
+
</div>
|
|
366
|
+
<form action="<%= main_app.dev_email_reply_path(@email[:id]) %>" method="post" id="reply-form">
|
|
367
|
+
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
|
|
368
|
+
<textarea name="body" rows="4" placeholder="Type a reply..."></textarea>
|
|
369
|
+
<div class="reply-actions">
|
|
370
|
+
<button type="submit" class="btn-reply" id="reply-btn">Send Reply</button>
|
|
371
|
+
</div>
|
|
372
|
+
</form>
|
|
373
|
+
<% else %>
|
|
374
|
+
<div class="no-reply-hint">No reply-to address — this email cannot receive simulated replies.</div>
|
|
375
|
+
<% end %>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<script>
|
|
381
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
382
|
+
var form = document.getElementById('reply-form');
|
|
383
|
+
if (!form) return;
|
|
384
|
+
form.addEventListener('submit', function() {
|
|
385
|
+
var btn = document.getElementById('reply-btn');
|
|
386
|
+
btn.disabled = true;
|
|
387
|
+
btn.textContent = 'Sending...';
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
</script>
|
|
391
|
+
</body>
|
|
392
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResendRobot
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path("templates", __dir__)
|
|
7
|
+
|
|
8
|
+
desc "Install Resend Robot initializer and Claude skills"
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "initializer.rb", "config/initializers/resend_robot.rb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def copy_skills
|
|
15
|
+
directory "skills/resend-robot-read", ".claude/skills/resend-robot-read"
|
|
16
|
+
directory "skills/resend-robot-send", ".claude/skills/resend-robot-send"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show_post_install
|
|
20
|
+
say ""
|
|
21
|
+
say "Resend Robot installed!", :green
|
|
22
|
+
say ""
|
|
23
|
+
say " Initializer: config/initializers/resend_robot.rb"
|
|
24
|
+
say " Claude skills: .claude/skills/resend-robot-read/"
|
|
25
|
+
say " .claude/skills/resend-robot-send/"
|
|
26
|
+
say ""
|
|
27
|
+
say "Next steps:", :yellow
|
|
28
|
+
say " 1. Edit config/initializers/resend_robot.rb to set your reply_domain"
|
|
29
|
+
say " 2. Start your dev server (bin/dev) and visit /dev/email"
|
|
30
|
+
say " 3. Use /resend-robot-read and /resend-robot-send in Claude"
|
|
31
|
+
say ""
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Configure Resend Robot (dev-mode Resend API shim).
|
|
4
|
+
# The gem auto-installs shims and routes — only app-specific config goes here.
|
|
5
|
+
#
|
|
6
|
+
# Run `bin/rails generate resend_robot:install` to regenerate this file.
|
|
7
|
+
if defined?(ResendRobot)
|
|
8
|
+
ResendRobot.configure do |config|
|
|
9
|
+
# Domain for inbound email simulation (e.g., "reply.example.com").
|
|
10
|
+
# Required for `resend_robot:reply` and `resend_robot:receive` tasks.
|
|
11
|
+
# config.reply_domain = "reply.example.com"
|
|
12
|
+
|
|
13
|
+
# Path where inbound webhook POSTs are sent (default: "/webhooks/resend")
|
|
14
|
+
# config.webhook_path = "/webhooks/resend"
|
|
15
|
+
|
|
16
|
+
# Port for localhost webhook POST and browser open (auto-detected from
|
|
17
|
+
# action_mailer.default_url_options[:port], fallback: 3000)
|
|
18
|
+
# config.dev_port = 3000
|
|
19
|
+
|
|
20
|
+
# Auto-open emails in the browser when sent (default: true)
|
|
21
|
+
# config.open_in_browser = true
|
|
22
|
+
|
|
23
|
+
# URL path for the web UI (default: "/dev/email")
|
|
24
|
+
# config.mount_path = "/dev/email"
|
|
25
|
+
|
|
26
|
+
# Storage path for JSON email files (default: tmp/resend_robot)
|
|
27
|
+
# config.storage_path = Rails.root.join("tmp/resend_robot")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: resend-robot-read
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: |
|
|
5
|
+
Read dev mailbox. Lists sent emails, shows email details, searches by
|
|
6
|
+
recipient or subject. Uses Resend Robot's local Resend shim.
|
|
7
|
+
allowed-tools:
|
|
8
|
+
- Bash
|
|
9
|
+
- Read
|
|
10
|
+
- Grep
|
|
11
|
+
- Glob
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Read Dev Email
|
|
15
|
+
|
|
16
|
+
You are running the `/resend-robot-read` workflow. Use Resend Robot to inspect emails sent in development.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
### List recent emails
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bin/rails resend_robot:outbox
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Shows a table of recent outbound emails: ID, time, recipient, subject.
|
|
27
|
+
|
|
28
|
+
### Show specific email
|
|
29
|
+
|
|
30
|
+
Read the JSON file directly for full details:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Find the file by ID
|
|
34
|
+
ls tmp/resend_robot/outbound/*_rl_XXXX*.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then use the Read tool on that file to see full HTML/text body, headers, tags, reply-to address.
|
|
38
|
+
|
|
39
|
+
### Search emails
|
|
40
|
+
|
|
41
|
+
Use Grep to search `tmp/resend_robot/outbound/` for a recipient email or subject:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Search by recipient
|
|
45
|
+
grep -l "alice@example.com" tmp/resend_robot/outbound/*.json
|
|
46
|
+
|
|
47
|
+
# Search by subject
|
|
48
|
+
grep -l "Welcome" tmp/resend_robot/outbound/*.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Storage
|
|
52
|
+
|
|
53
|
+
- Emails are stored as JSON in `tmp/resend_robot/outbound/`
|
|
54
|
+
- Each file has: id, from, to, cc, reply_to, subject, html, text, tags, headers
|
|
55
|
+
- Files are named `{timestamp}_{id}.json` — newest files sort last alphabetically
|
|
56
|
+
- The dev UI (default: `/dev/email`) shows the same data with HTML preview and reply form
|
|
57
|
+
|
|
58
|
+
## Rake Tasks Reference
|
|
59
|
+
|
|
60
|
+
| Task | Description |
|
|
61
|
+
|------|-------------|
|
|
62
|
+
| `resend_robot:outbox` | List recent outbound emails (table format) |
|
|
63
|
+
| `resend_robot:show[ID]` | Show full JSON for a specific email |
|
|
64
|
+
| `resend_robot:clear` | Delete all stored emails |
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: resend-robot-send
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: |
|
|
5
|
+
Simulate receiving an inbound email in dev. Sends through the app's
|
|
6
|
+
webhook pipeline via Resend Robot's local shim.
|
|
7
|
+
allowed-tools:
|
|
8
|
+
- Bash
|
|
9
|
+
- Read
|
|
10
|
+
- Grep
|
|
11
|
+
- Glob
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Send Dev Email (Simulate Inbound)
|
|
15
|
+
|
|
16
|
+
You are running the `/resend-robot-send` workflow. Use Resend Robot to simulate receiving an inbound email through the app's webhook pipeline.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
### Reply to most recent outbound email
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bin/rails "resend_robot:reply[0,Thanks for the email!]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This finds the most recent outbound email, uses its recipient as `from` and its reply-to address as `to`, then:
|
|
27
|
+
1. Stores the inbound email JSON
|
|
28
|
+
2. POSTs to the webhook endpoint (default: `/webhooks/resend`)
|
|
29
|
+
3. The app's webhook controller processes it through the normal pipeline
|
|
30
|
+
|
|
31
|
+
The first argument is the index (0 = most recent, 1 = second most recent, etc.).
|
|
32
|
+
|
|
33
|
+
### Reply to a specific outbound email
|
|
34
|
+
|
|
35
|
+
First find the email index:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
bin/rails resend_robot:outbox
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Then reply to it:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bin/rails "resend_robot:reply[2,I have a question about this]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Send from a specific address
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bin/rails "resend_robot:receive[stranger@gmail.com,,Question about something,Is this available?]"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Arguments: `[from, to, subject, body]`
|
|
54
|
+
|
|
55
|
+
- If `to` is empty and `reply_domain` is configured, Resend Robot auto-generates a `cold-{hex}@{reply_domain}` address
|
|
56
|
+
- The `to` address should match your app's inbound email domain for the webhook controller to accept it
|
|
57
|
+
|
|
58
|
+
## Common Patterns
|
|
59
|
+
|
|
60
|
+
- **Reply to last email:** `resend_robot:reply[0,Thanks! Got it.]`
|
|
61
|
+
- **Cold inbound:** `resend_robot:receive[stranger@gmail.com,,Question,Is this available?]`
|
|
62
|
+
- **Test full cycle:** Send email via app -> `/resend-robot-read` to verify -> `/resend-robot-send` to reply -> check app state
|
|
63
|
+
|
|
64
|
+
## Important
|
|
65
|
+
|
|
66
|
+
- The dev server must be running (`bin/dev`) for inbound simulation to work
|
|
67
|
+
- All inbound emails go through the REAL app webhook pipeline (same code path as prod)
|
|
68
|
+
- If your app processes inbound emails asynchronously, poll for completion after sending
|
|
69
|
+
|
|
70
|
+
## Rake Tasks Reference
|
|
71
|
+
|
|
72
|
+
| Task | Description |
|
|
73
|
+
|------|-------------|
|
|
74
|
+
| `resend_robot:reply[INDEX,BODY]` | Reply to the Nth most recent outbound email |
|
|
75
|
+
| `resend_robot:receive[FROM,TO,SUBJECT,BODY]` | Simulate receiving an email from a specific address |
|
|
76
|
+
| `resend_robot:outbox` | List recent outbound emails |
|
|
77
|
+
| `resend_robot:clear` | Delete all stored emails |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResendRobot
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :storage_path, # Pathname — where to store JSON files
|
|
6
|
+
:reply_domain, # String — domain for inbound email simulation (nil disables ensure_reply_domain)
|
|
7
|
+
:webhook_path, # String — path to POST inbound webhooks
|
|
8
|
+
:dev_port, # Integer — port for localhost webhook POST + browser open
|
|
9
|
+
:open_in_browser, # Boolean — auto-open emails in browser
|
|
10
|
+
:mount_path, # String — URL path for the web UI
|
|
11
|
+
:logger # Logger instance
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@storage_path = nil # resolved lazily via resolved_storage_path
|
|
15
|
+
@reply_domain = nil
|
|
16
|
+
@webhook_path = "/webhooks/resend"
|
|
17
|
+
@dev_port = 3000
|
|
18
|
+
@open_in_browser = true
|
|
19
|
+
@mount_path = "/dev/email"
|
|
20
|
+
@logger = nil # resolved lazily via resolved_logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def resolved_storage_path
|
|
24
|
+
@storage_path || default_storage_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolved_logger
|
|
28
|
+
@logger || default_logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def default_storage_path
|
|
34
|
+
if defined?(Rails) && Rails.root
|
|
35
|
+
Rails.root.join("tmp/resend_robot")
|
|
36
|
+
else
|
|
37
|
+
Pathname.new("tmp/resend_robot")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_logger
|
|
42
|
+
if defined?(Rails) && Rails.logger
|
|
43
|
+
Rails.logger
|
|
44
|
+
else
|
|
45
|
+
require "logger"
|
|
46
|
+
Logger.new($stdout)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResendRobot
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace ResendRobot
|
|
6
|
+
|
|
7
|
+
# Auto-install shims in development (replaces the old initializer)
|
|
8
|
+
initializer "resend_robot.install_shim" do
|
|
9
|
+
next unless Rails.env.local? && ENV["RESEND_IN_DEV"] != "1"
|
|
10
|
+
|
|
11
|
+
Rails.application.config.after_initialize do
|
|
12
|
+
require "resend_robot/shim"
|
|
13
|
+
Resend.api_key ||= "rl_dev_dummy_key" if defined?(Resend)
|
|
14
|
+
ResendRobot::Shim.install!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Auto-detect dev_port from action_mailer config
|
|
19
|
+
initializer "resend_robot.detect_port" do
|
|
20
|
+
Rails.application.config.after_initialize do
|
|
21
|
+
detected_port = Rails.application.config.action_mailer.default_url_options&.dig(:port)
|
|
22
|
+
ResendRobot.configuration.dev_port = detected_port if detected_port
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Auto-inject routes so users don't need a mount line
|
|
27
|
+
initializer "resend_robot.add_routes", after: :add_routing_paths do |app|
|
|
28
|
+
mount_path = ResendRobot.configuration.mount_path
|
|
29
|
+
|
|
30
|
+
app.routes.prepend do
|
|
31
|
+
constraints ->(_) { Rails.env.local? } do
|
|
32
|
+
scope module: :resend_robot do
|
|
33
|
+
get "#{mount_path}", to: "mailbox#index", as: :dev_email_index
|
|
34
|
+
get "#{mount_path}/:id", to: "mailbox#show", as: :dev_email_preview
|
|
35
|
+
post "#{mount_path}/:id/reply", to: "mailbox#reply", as: :dev_email_reply
|
|
36
|
+
delete "#{mount_path}/clear", to: "mailbox#clear", as: :dev_email_clear
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load rake tasks
|
|
43
|
+
rake_tasks do
|
|
44
|
+
load "resend_robot/tasks/resend_robot.rake"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|