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
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>
|