ses-dashboard 0.1.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7165bdb98b1bf82aa3b6a65e7346101bc9792eb81e7b81fd8c1c9128865697f
4
- data.tar.gz: 520bc27a98c191b442b307bb7878cc4566883d2567db36373ea62396e867748b
3
+ metadata.gz: 9efac9cf44528ae2052092bfcef569f312d9da609f23039459515ddc54a67072
4
+ data.tar.gz: 387467a34a67ec49a15241222b6a9dc077abcda5c545c872737e152b22d82905
5
5
  SHA512:
6
- metadata.gz: c77bbac1e82db66182c2bd4141d8ab66ea9e9fb267dc0f33b0a082add3af7bf5ed72bdfb7c6bb2dd772d5db4ae19db61b120681d8f6d8001816ed90dbb8f7dfc
7
- data.tar.gz: 8e3f511a3be5108a96247ff6fb42cfa05aed17003637f9d43c92863b6629a653cfd71ce74f5606952750a0655018fbfb01c8d06eedfd8f89cfdd6dce7f2b12d9
6
+ metadata.gz: 771334e8e2d8d0459d1c462022a69608958753d67845e5100bea2365fbfc9e47e1bc9f3c49e5befd804e456a5de22b3b0c23f98f6a86fe3cf4d223222eac9dbd
7
+ data.tar.gz: 922d8c07c25a5beac7beda1ec6bcecbb483c852f2bbe031aa65003ff181982c22a7b052c8d5e0efb5cdfda99d3339a1ccd113fc3bc4a2481dbe55ac62b656ea7
data/README.md CHANGED
@@ -1,7 +1,51 @@
1
1
  # SES Dashboard
2
+ [![codecov](https://codecov.io/gh/antodoms/ses_dashboard/graph/badge.svg?token=0SSO12E12W)](https://codecov.io/gh/antodoms/ses_dashboard)
3
+ [![Gem Version](https://badge.fury.io/rb/ses-dashboard.svg)](https://badge.fury.io/rb/ses-dashboard)
2
4
 
3
5
  A mountable Rails engine that provides a real-time dashboard for Amazon SES, tracking email delivery, bounces, complaints, opens, and clicks via SNS webhooks.
4
6
 
7
+ ## Screenshots
8
+
9
+ ![Combined Dashboard](docs/screenshots/combined-projects-dashboard.png)
10
+
11
+ <table>
12
+ <tr>
13
+ <td><img src="docs/screenshots/project-dashboard-view-page.png" alt="Project Dashboard" /></td>
14
+ <td><img src="docs/screenshots/project-activity-page.png" alt="Activity Log" /></td>
15
+ </tr>
16
+ <tr>
17
+ <td align="center"><em>Per-project stats &amp; email volume chart</em></td>
18
+ <td align="center"><em>Paginated activity log with search &amp; export</em></td>
19
+ </tr>
20
+ <tr>
21
+ <td colspan="2"><img src="docs/screenshots/email-details.png" alt="Email Detail" /></td>
22
+ </tr>
23
+ <tr>
24
+ <td colspan="2" align="center"><em>Email detail with full SNS event timeline</em></td>
25
+ </tr>
26
+ <tr>
27
+ <td colspan="2"><img src="docs/screenshots/webhook-forward-ruleset.png" alt="Webhook Forward Rules" /></td>
28
+ </tr>
29
+ <tr>
30
+ <td colspan="2" align="center"><em>Per-project webhook forwarding with configurable rules</em></td>
31
+ </tr>
32
+ </table>
33
+
34
+ ## Features
35
+
36
+ - **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
37
+ - **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
38
+ - **Webhook forwarding with rules** -- forward specific events to external URLs (e.g. Zapier) with a configurable rules engine; filter by event type, sender, recipient, subject, and more
39
+ - **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
40
+ - **CSV/JSON export** -- export filtered email activity from any project
41
+ - **Test email sending** -- send test emails directly from the dashboard via the SES API
42
+ - **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
43
+ - **SNS signature verification** -- validates RSA signatures (SHA1 and SHA256) on incoming SNS messages
44
+ - **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
45
+ - **Lightweight pagination** -- no external pagination gem required
46
+
47
+ ## Architecture
48
+
5
49
  ```mermaid
6
50
  graph TB
7
51
  subgraph Host["Host Rails App"]
@@ -29,18 +73,21 @@ graph TB
29
73
  subgraph Core["Core Library (lib/)"]
30
74
  Client["Client<br/>AWS SES SDK wrapper"]
31
75
  WP["WebhookProcessor<br/>SNS message parser"]
76
+ SV["SnsSignatureVerifier"]
32
77
  SA["StatsAggregator<br/>Dashboard statistics"]
33
78
  Pag["Paginatable"]
79
+ FR["ForwardRule<br/>field/operator/value matcher"]
34
80
  end
35
81
 
36
82
  subgraph Models
37
- Project["Project<br/>name, token"]
83
+ Project["Project<br/>name, token, webhook_forwards"]
38
84
  Email["Email<br/>status, opens, clicks"]
39
85
  Event["EmailEvent<br/>event_type, event_data"]
40
86
  end
41
87
 
42
88
  subgraph Services
43
89
  WEP["WebhookEventPersistor"]
90
+ WF["WebhookForwarder<br/>rules-based HTTP forwarding"]
44
91
  end
45
92
  end
46
93
 
@@ -54,8 +101,12 @@ graph TB
54
101
  DC --> SA
55
102
  EC --> Pag
56
103
  TC --> Client
57
- WC -->|"POST /webhook/:token"| WP
104
+ WC -->|"POST /webhook/:token"| SV
105
+ SV --> WP
58
106
  WP --> WEP
107
+ WP --> WF
108
+ WF --> FR
109
+ WF -->|"HTTP POST"| ExtURL["External URL<br/>(Zapier, etc.)"]
59
110
  WEP --> Models
60
111
  Client --> SES
61
112
  SNS -->|"HTTP POST"| WC
@@ -66,33 +117,25 @@ graph TB
66
117
  style Engine fill:#f0f4ff,stroke:#3366cc
67
118
  style AWS fill:#fff3e0,stroke:#ff9800
68
119
  style Host fill:#e8f5e9,stroke:#4caf50
120
+ style ExtURL fill:#fce4ec,stroke:#e91e63
69
121
  ```
70
122
 
71
- ## Features
72
-
73
- - **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
74
- - **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
75
- - **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
76
- - **CSV/JSON export** -- export filtered email activity from any project
77
- - **Test email sending** -- send test emails directly from the dashboard via the SES API
78
- - **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
79
- - **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
80
- - **Lightweight pagination** -- no external pagination gem required
81
-
82
123
  ## Installation
83
124
 
84
125
  Add the gem to your Gemfile:
85
126
 
86
127
  ```ruby
87
- gem "ses_dashboard"
128
+ gem "ses-dashboard"
88
129
  ```
89
130
 
131
+ The gem name uses a hyphen (`ses-dashboard`) — Bundler will auto-require the correct entry point automatically.
132
+
90
133
  Then run:
91
134
 
92
135
  ```bash
93
136
  bundle install
94
- rails ses_dashboard:install:migrations
95
- rails db:migrate
137
+ bin/rails railties:install:migrations
138
+ bin/rails db:migrate
96
139
  ```
97
140
 
98
141
  ## Mounting
@@ -128,8 +171,8 @@ SesDashboard.configure do |c|
128
171
  c.cloudflare_aud = "your-application-aud"
129
172
 
130
173
  # Dashboard behaviour
131
- c.per_page = 25 # rows per page in the activity log
132
- c.time_zone = "UTC" # timezone for chart date grouping
174
+ c.per_page = 25 # rows per page in the activity log
175
+ c.time_zone = "UTC" # timezone for chart date grouping
133
176
  c.test_email_from = "noreply@example.com"
134
177
 
135
178
  # Caching & security
@@ -144,24 +187,55 @@ Every controller action (except the webhook endpoint) runs through the configure
144
187
 
145
188
  | Adapter | Value | Notes |
146
189
  |---|---|---|
147
- | None | `:none` | Open access -- suitable for development |
190
+ | None | `:none` | Open access suitable for development |
148
191
  | Devise | `:devise` | Calls `authenticate_user!` via Warden |
149
192
  | Cloudflare Zero Trust | `:cloudflare` | Validates `CF_Authorization` JWT against JWKS |
150
193
  | Custom | any object | Must respond to `#authenticate(request)` returning truthy/falsy |
151
194
 
195
+ ### Custom adapter
196
+
197
+ Use a custom adapter when your app has its own authentication system (e.g. custom session-based auth, API keys, JWT):
198
+
199
+ ```ruby
200
+ # config/initializers/ses_dashboard.rb
201
+
202
+ my_auth = Class.new(SesDashboard::Auth::Base) do
203
+ def authenticate(request)
204
+ session = request.session
205
+ # your auth logic here — return truthy to allow, falsy to deny
206
+ session[:user_id].present?
207
+ end
208
+ end
209
+
210
+ SesDashboard.configure do |c|
211
+ c.authentication_adapter = my_auth.new
212
+ end
213
+ ```
214
+
215
+ For apps with session timeout and whitelist checks (e.g. custom Rails session auth):
216
+
152
217
  ```ruby
153
- # Example custom adapter
154
- class ApiKeyAuth
218
+ my_auth = Class.new(SesDashboard::Auth::Base) do
155
219
  def authenticate(request)
156
- request.headers["X-Api-Key"] == Rails.application.credentials.dashboard_key
220
+ session = request.session
221
+ user_id = session[:user_id]
222
+ logged_in_at = session[:logged_in_at]
223
+
224
+ return false unless user_id && logged_in_at
225
+ return false unless logged_in_at > 12.hours.ago
226
+
227
+ user = User.find_by(id: user_id)
228
+ user&.active? || false
157
229
  end
158
230
  end
159
231
 
160
232
  SesDashboard.configure do |c|
161
- c.authentication_adapter = ApiKeyAuth.new
233
+ c.authentication_adapter = my_auth.new
162
234
  end
163
235
  ```
164
236
 
237
+ The adapter is defined inline using `Class.new` so it is available at initializer load time without depending on Zeitwerk autoloading.
238
+
165
239
  ## SNS Webhook Setup
166
240
 
167
241
  Each project gets a unique webhook URL displayed on its dashboard page:
@@ -178,17 +252,124 @@ To connect it to SES:
178
252
 
179
253
  The webhook endpoint authenticates via the project token in the URL and does not require a session.
180
254
 
181
- ## Database Schema
255
+ ### SNS Signature Verification
182
256
 
183
- The engine creates three tables (prefixed `ses_dashboard_`):
257
+ When `verify_sns_signature = true`, the engine validates the RSA signature on every incoming SNS message before processing it. Both `SignatureVersion` `"1"` (SHA1) and `"2"` (SHA256) are supported.
184
258
 
185
- | Table | Key Columns |
259
+ For **raw message delivery** (SNS subscription setting), signature verification is automatically skipped as SNS does not include signature fields in raw payloads — the project token in the URL provides authentication instead.
260
+
261
+ Enable in production:
262
+
263
+ ```ruby
264
+ c.verify_sns_signature = Rails.env.production?
265
+ ```
266
+
267
+ ## Webhook Forwarding
268
+
269
+ Forward SES events to external URLs (e.g. Zapier, Google Sheets, Slack) based on configurable rules. This is useful for routing bounce/complaint notifications into team workflows without consuming unnecessary webhook quota on events you don't need.
270
+
271
+ ![Webhook Forward Rules](docs/screenshots/webhook-forward-ruleset.png)
272
+
273
+ ### Per-project configuration (UI)
274
+
275
+ Edit any project in the dashboard and use the **Webhook Forwards** builder to add forward targets with rules. Each target has a URL and optional rules -- all rules must match (AND logic) for the event to be forwarded. No rules means forward everything.
276
+
277
+ ### Per-project configuration (database)
278
+
279
+ The `webhook_forwards` column on `ses_dashboard_projects` stores a JSON array:
280
+
281
+ ```json
282
+ [
283
+ {
284
+ "url": "https://hooks.zapier.com/hooks/catch/123/abc/",
285
+ "rules": [
286
+ { "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] }
287
+ ]
288
+ }
289
+ ]
290
+ ```
291
+
292
+ ### Global fallback (initializer)
293
+
294
+ Used for any project that has no project-level forwards configured:
295
+
296
+ ```ruby
297
+ SesDashboard.configure do |c|
298
+ c.webhook_forwards = [
299
+ { url: "https://hooks.zapier.com/hooks/catch/123/abc/",
300
+ rules: [{ field: "event_type", operator: "in", value: ["bounce", "complaint"] }] }
301
+ ]
302
+ end
303
+ ```
304
+
305
+ ### Rules reference
306
+
307
+ Each rule has a `field`, `operator`, and `value`. All rules on a target must match for the event to be forwarded.
308
+
309
+ | Field | Type | Description |
310
+ |---|---|---|
311
+ | `event_type` | string | `send`, `delivery`, `bounce`, `complaint`, `open`, `click`, `reject`, `rendering_failure` |
312
+ | `source` | string | From: email address |
313
+ | `destination` | array | To: email addresses -- rule passes if **any** recipient matches |
314
+ | `subject` | string | Email subject line |
315
+
316
+ | Operator | Description |
186
317
  |---|---|
187
- | `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description` |
188
- | `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
189
- | `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
318
+ | `in` | Value is included in the given array |
319
+ | `not_in` | Value is NOT included in the given array |
320
+ | `eq` | Exact string equality (any element for array fields) |
321
+ | `not_eq` | String inequality |
322
+ | `starts_with` | Prefix match (any element for array fields) |
323
+ | `ends_with` | Suffix match (any element for array fields) |
324
+ | `contains` | Substring match (any element for array fields) |
325
+
326
+ ### Forwarded payload
327
+
328
+ Each matching URL receives a `POST` with a JSON body:
329
+
330
+ ```json
331
+ {
332
+ "event_type": "bounce",
333
+ "message_id": "0102018e-abcd-1234-...",
334
+ "source": "noreply@myapp.com",
335
+ "destination": ["user@example.com"],
336
+ "subject": "Your invoice #1234",
337
+ "occurred_at": "2024-01-15T10:00:00Z",
338
+ "raw": { "...full SES event hash..." }
339
+ }
340
+ ```
190
341
 
191
- Email statuses: `sent`, `delivered`, `bounced`, `complained`, `rejected`, `failed`.
342
+ ### Examples
343
+
344
+ Forward only bounces and complaints to Zapier:
345
+
346
+ ```json
347
+ [
348
+ {
349
+ "url": "https://hooks.zapier.com/hooks/catch/123/abc/",
350
+ "rules": [
351
+ { "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] }
352
+ ]
353
+ }
354
+ ]
355
+ ```
356
+
357
+ Forward bounces from a specific sender domain to one URL, all events to another:
358
+
359
+ ```json
360
+ [
361
+ {
362
+ "url": "https://hooks.zapier.com/bounces",
363
+ "rules": [
364
+ { "field": "event_type", "operator": "eq", "value": "bounce" },
365
+ { "field": "source", "operator": "ends_with", "value": "@myapp.com" }
366
+ ]
367
+ },
368
+ {
369
+ "url": "https://example.com/all-events"
370
+ }
371
+ ]
372
+ ```
192
373
 
193
374
  ## Development
194
375
 
@@ -226,13 +407,27 @@ bundle exec rspec spec/models/ses_dashboard/email_spec.rb:15
226
407
  | Service | Purpose | Ports |
227
408
  |---|---|---|
228
409
  | `localstack` | Local AWS (SES + SNS) | 4566 |
229
- | `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC -- watch tests live) |
410
+ | `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC watch tests live) |
230
411
  | `web` | Runs the test suite | 4001 (Puma) |
231
412
 
232
413
  ### Watching System Tests
233
414
 
234
415
  Open http://localhost:7900 in your browser (no password) to watch Chrome execute system specs in real time via noVNC.
235
416
 
417
+ ## Database Schema
418
+
419
+ The engine creates three tables (prefixed `ses_dashboard_`):
420
+
421
+ | Table | Key Columns |
422
+ |---|---|
423
+ | `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description`, `webhook_forwards` (JSON) |
424
+ | `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
425
+ | `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
426
+
427
+ Email statuses: `sent`, `delivered`, `bounced`, `complained`, `rejected`, `failed`.
428
+
429
+ Migrations are compatible with Rails 7.x and 8.x — the migration version is resolved automatically from the host app's Rails version at install time.
430
+
236
431
  ## License
237
432
 
238
433
  MIT
@@ -115,6 +115,268 @@
115
115
  });
116
116
  }
117
117
 
118
+ // ── Webhook Forwards builder ──────────────────────────────────────────
119
+ var WF_FIELDS = [
120
+ { value: "event_type", label: "Event Type" },
121
+ { value: "source", label: "From (source)" },
122
+ { value: "destination", label: "To (destination)" },
123
+ { value: "subject", label: "Subject" }
124
+ ];
125
+
126
+ var WF_OPERATORS = [
127
+ { value: "in", label: "is in" },
128
+ { value: "not_in", label: "is not in" },
129
+ { value: "eq", label: "equals" },
130
+ { value: "not_eq", label: "does not equal" },
131
+ { value: "starts_with", label: "starts with" },
132
+ { value: "ends_with", label: "ends with" },
133
+ { value: "contains", label: "contains" }
134
+ ];
135
+
136
+ var WF_EVENT_TYPES = [
137
+ "send", "delivery", "bounce", "complaint", "open", "click", "reject", "rendering_failure"
138
+ ];
139
+
140
+ function wfBuildSelect(options, selected, className) {
141
+ var sel = document.createElement("select");
142
+ sel.className = "form-control " + className;
143
+ options.forEach(function (opt) {
144
+ var o = document.createElement("option");
145
+ o.value = opt.value;
146
+ o.textContent = opt.label;
147
+ if (opt.value === selected) o.selected = true;
148
+ sel.appendChild(o);
149
+ });
150
+ return sel;
151
+ }
152
+
153
+ function wfBuildCheckboxes(selected) {
154
+ var wrap = document.createElement("div");
155
+ wrap.className = "wf-checkboxes";
156
+ var selectedArr = Array.isArray(selected) ? selected : [];
157
+ WF_EVENT_TYPES.forEach(function (et) {
158
+ var label = document.createElement("label");
159
+ label.className = "wf-checkbox-label";
160
+ var cb = document.createElement("input");
161
+ cb.type = "checkbox";
162
+ cb.value = et;
163
+ cb.className = "wf-event-cb";
164
+ if (selectedArr.indexOf(et) !== -1) cb.checked = true;
165
+ label.appendChild(cb);
166
+ label.appendChild(document.createTextNode(" " + et));
167
+ wrap.appendChild(label);
168
+ });
169
+ return wrap;
170
+ }
171
+
172
+ function wfBuildTextInput(value, placeholder) {
173
+ var input = document.createElement("input");
174
+ input.type = "text";
175
+ input.className = "form-control wf-value";
176
+ input.placeholder = placeholder || "";
177
+ input.value = value || "";
178
+ return input;
179
+ }
180
+
181
+ function wfIsArrayOperator(op) {
182
+ return op === "in" || op === "not_in";
183
+ }
184
+
185
+ function wfBuildValueInput(field, operator, value) {
186
+ if (field === "event_type" && wfIsArrayOperator(operator)) {
187
+ return wfBuildCheckboxes(value);
188
+ }
189
+ if (wfIsArrayOperator(operator)) {
190
+ var display = Array.isArray(value) ? value.join(", ") : (value || "");
191
+ return wfBuildTextInput(display, "value1, value2, value3");
192
+ }
193
+ return wfBuildTextInput(value || "", "Value");
194
+ }
195
+
196
+ function wfReadValue(valueContainer) {
197
+ // Checkboxes
198
+ var cbs = valueContainer.querySelectorAll(".wf-event-cb");
199
+ if (cbs.length > 0) {
200
+ var vals = [];
201
+ cbs.forEach(function (cb) { if (cb.checked) vals.push(cb.value); });
202
+ return vals;
203
+ }
204
+ // Text input
205
+ var input = valueContainer.querySelector(".wf-value");
206
+ if (input) {
207
+ var sel = valueContainer.closest(".wf-rule").querySelector(".wf-operator");
208
+ if (sel && wfIsArrayOperator(sel.value)) {
209
+ return input.value.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
210
+ }
211
+ return input.value;
212
+ }
213
+ return "";
214
+ }
215
+
216
+ function wfSwapValueInput(ruleEl) {
217
+ var fieldSel = ruleEl.querySelector(".wf-field");
218
+ var opSel = ruleEl.querySelector(".wf-operator");
219
+ var valueWrap = ruleEl.querySelector(".wf-value-wrap");
220
+ var currentValue = wfReadValue(valueWrap);
221
+ valueWrap.innerHTML = "";
222
+ valueWrap.appendChild(wfBuildValueInput(fieldSel.value, opSel.value, currentValue));
223
+ }
224
+
225
+ function wfBuildRule(rule) {
226
+ rule = rule || {};
227
+ var field = rule.field || "event_type";
228
+ var operator = rule.operator || "in";
229
+ var value = rule.value || (wfIsArrayOperator(operator) ? [] : "");
230
+
231
+ var row = document.createElement("div");
232
+ row.className = "wf-rule";
233
+
234
+ var fieldSel = wfBuildSelect(WF_FIELDS, field, "wf-field");
235
+ var opSel = wfBuildSelect(WF_OPERATORS, operator, "wf-operator");
236
+
237
+ var valueWrap = document.createElement("div");
238
+ valueWrap.className = "wf-value-wrap";
239
+ valueWrap.appendChild(wfBuildValueInput(field, operator, value));
240
+
241
+ var removeBtn = document.createElement("button");
242
+ removeBtn.type = "button";
243
+ removeBtn.className = "btn btn-danger btn-sm";
244
+ removeBtn.textContent = "\u00d7";
245
+ removeBtn.addEventListener("click", function () { row.remove(); });
246
+
247
+ // Swap value input when field or operator changes
248
+ fieldSel.addEventListener("change", function () { wfSwapValueInput(row); });
249
+ opSel.addEventListener("change", function () { wfSwapValueInput(row); });
250
+
251
+ row.appendChild(fieldSel);
252
+ row.appendChild(opSel);
253
+ row.appendChild(valueWrap);
254
+ row.appendChild(removeBtn);
255
+ return row;
256
+ }
257
+
258
+ function wfBuildTarget(target) {
259
+ target = target || {};
260
+ var card = document.createElement("div");
261
+ card.className = "wf-target card";
262
+
263
+ // Header
264
+ var header = document.createElement("div");
265
+ header.className = "wf-target-header";
266
+ var title = document.createElement("span");
267
+ title.className = "wf-target-title";
268
+ title.textContent = "Forward Target";
269
+ var removeBtn = document.createElement("button");
270
+ removeBtn.type = "button";
271
+ removeBtn.className = "btn btn-danger btn-sm";
272
+ removeBtn.textContent = "Remove";
273
+ removeBtn.addEventListener("click", function () { card.remove(); wfUpdateNumbers(); });
274
+ header.appendChild(title);
275
+ header.appendChild(removeBtn);
276
+ card.appendChild(header);
277
+
278
+ // URL
279
+ var urlGroup = document.createElement("div");
280
+ urlGroup.className = "form-group";
281
+ var urlLabel = document.createElement("label");
282
+ urlLabel.className = "form-label";
283
+ urlLabel.textContent = "URL";
284
+ var urlInput = document.createElement("input");
285
+ urlInput.type = "text";
286
+ urlInput.className = "form-control wf-url";
287
+ urlInput.placeholder = "https://hooks.zapier.com/hooks/catch/...";
288
+ urlInput.value = target.url || "";
289
+ urlGroup.appendChild(urlLabel);
290
+ urlGroup.appendChild(urlInput);
291
+ card.appendChild(urlGroup);
292
+
293
+ // Rules
294
+ var rulesLabel = document.createElement("label");
295
+ rulesLabel.className = "form-label";
296
+ rulesLabel.textContent = "Rules";
297
+ rulesLabel.style.marginBottom = ".25rem";
298
+ card.appendChild(rulesLabel);
299
+
300
+ var rulesWrap = document.createElement("div");
301
+ rulesWrap.className = "wf-rules";
302
+ var rules = target.rules || [];
303
+ rules.forEach(function (r) { rulesWrap.appendChild(wfBuildRule(r)); });
304
+ card.appendChild(rulesWrap);
305
+
306
+ var addRuleBtn = document.createElement("button");
307
+ addRuleBtn.type = "button";
308
+ addRuleBtn.className = "btn btn-outline btn-sm";
309
+ addRuleBtn.textContent = "+ Add Rule";
310
+ addRuleBtn.addEventListener("click", function () {
311
+ rulesWrap.appendChild(wfBuildRule());
312
+ });
313
+ card.appendChild(addRuleBtn);
314
+
315
+ return card;
316
+ }
317
+
318
+ function wfUpdateNumbers() {
319
+ var targets = document.querySelectorAll(".wf-target");
320
+ targets.forEach(function (t, i) {
321
+ t.querySelector(".wf-target-title").textContent = "Forward Target #" + (i + 1);
322
+ });
323
+ }
324
+
325
+ function wfSerialize() {
326
+ var targets = document.querySelectorAll(".wf-target");
327
+ var result = [];
328
+ targets.forEach(function (t) {
329
+ var url = t.querySelector(".wf-url").value.trim();
330
+ if (!url) return;
331
+ var rules = [];
332
+ t.querySelectorAll(".wf-rule").forEach(function (r) {
333
+ var field = r.querySelector(".wf-field").value;
334
+ var op = r.querySelector(".wf-operator").value;
335
+ var val = wfReadValue(r.querySelector(".wf-value-wrap"));
336
+ if (wfIsArrayOperator(op) && Array.isArray(val) && val.length === 0) return;
337
+ if (!wfIsArrayOperator(op) && val === "") return;
338
+ rules.push({ field: field, operator: op, value: val });
339
+ });
340
+ var entry = { url: url };
341
+ if (rules.length > 0) entry.rules = rules;
342
+ result.push(entry);
343
+ });
344
+ return result;
345
+ }
346
+
347
+ function initWebhookForwardsBuilder() {
348
+ var container = document.getElementById("wf-targets");
349
+ var addBtn = document.getElementById("wf-add-target");
350
+ var hidden = document.getElementById("webhook-forwards-json");
351
+ if (!container || !addBtn || !hidden) return;
352
+
353
+ // Load initial data
354
+ var dataEl = document.getElementById("wf-initial-data");
355
+ var initial = [];
356
+ if (dataEl && dataEl.textContent.trim()) {
357
+ try { initial = JSON.parse(dataEl.textContent); } catch (e) { /* ignore */ }
358
+ }
359
+
360
+ initial.forEach(function (t) {
361
+ container.appendChild(wfBuildTarget(t));
362
+ });
363
+ wfUpdateNumbers();
364
+
365
+ addBtn.addEventListener("click", function () {
366
+ container.appendChild(wfBuildTarget());
367
+ wfUpdateNumbers();
368
+ });
369
+
370
+ // Serialize to hidden field on submit
371
+ var form = hidden.closest("form");
372
+ if (form) {
373
+ form.addEventListener("submit", function () {
374
+ var data = wfSerialize();
375
+ hidden.value = data.length > 0 ? JSON.stringify(data) : "";
376
+ });
377
+ }
378
+ }
379
+
118
380
  // ── Boot ─────────────────────────────────────────────────────────────
119
381
  document.addEventListener("DOMContentLoaded", function () {
120
382
  initChart();
@@ -122,5 +384,6 @@
122
384
  initEventToggles();
123
385
  initFilterReset();
124
386
  initConfirmLinks();
387
+ initWebhookForwardsBuilder();
125
388
  });
126
389
  })();