ses-dashboard 0.4.0 → 0.6.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: 58342ca28ca9d49b31764e988689df488d9a43061fdb145ded04a7d2dbb038b6
4
- data.tar.gz: da07fcf3d2549fc68bdf24aceb72d289e3a8bf5d93238cd807932b71ef4d7cec
3
+ metadata.gz: 1a5f8353ca85d9d05449584f9c48c8877135c23ba57b6db69544b9451084194c
4
+ data.tar.gz: 0eeb30e5ece7ec5b15bc11eece35e0c94f197b5fb7f85c5acce03c30425122a0
5
5
  SHA512:
6
- metadata.gz: 8757e8e5024c11d93826bdb890e46dcf6fef0bac259d43da0dbec7bd64e16f07765b01967a2d7b5bbfa75de2d11f9bf193b1949f81c937b2e450fba6b2c13092
7
- data.tar.gz: b222f9246a72fd94869c85e316a69668519536e8fc973e205036ce64ea92c02dd233a6268f889dde3a871759478a1a915e6379e4bed36845c41409acf5c2d208
6
+ metadata.gz: c3b1ee1fd3a8443c91531b8542471109285afe2e676e7dd1e51dc521abc9fe88f80cca61aed32b36bf3b24266242f7c0f9f030a4b17e5a9bb3fda7e9b0913046
7
+ data.tar.gz: d0f2491e684e3a6c286b58925eac22d0299d366b43e79b91b23765b05645b719a365e0d8b81ffe472c20186c345b377f4fc68d030115abc518a3b603c5bc5aab
data/README.md CHANGED
@@ -23,12 +23,19 @@ A mountable Rails engine that provides a real-time dashboard for Amazon SES, tra
23
23
  <tr>
24
24
  <td colspan="2" align="center"><em>Email detail with full SNS event timeline</em></td>
25
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>
26
32
  </table>
27
33
 
28
34
  ## Features
29
35
 
30
36
  - **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
31
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
32
39
  - **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
33
40
  - **CSV/JSON export** -- export filtered email activity from any project
34
41
  - **Test email sending** -- send test emails directly from the dashboard via the SES API
@@ -69,16 +76,18 @@ graph TB
69
76
  SV["SnsSignatureVerifier"]
70
77
  SA["StatsAggregator<br/>Dashboard statistics"]
71
78
  Pag["Paginatable"]
79
+ FR["ForwardRule<br/>field/operator/value matcher"]
72
80
  end
73
81
 
74
82
  subgraph Models
75
- Project["Project<br/>name, token"]
83
+ Project["Project<br/>name, token, webhook_forwards"]
76
84
  Email["Email<br/>status, opens, clicks"]
77
85
  Event["EmailEvent<br/>event_type, event_data"]
78
86
  end
79
87
 
80
88
  subgraph Services
81
89
  WEP["WebhookEventPersistor"]
90
+ WF["WebhookForwarder<br/>rules-based HTTP forwarding"]
82
91
  end
83
92
  end
84
93
 
@@ -95,6 +104,9 @@ graph TB
95
104
  WC -->|"POST /webhook/:token"| SV
96
105
  SV --> WP
97
106
  WP --> WEP
107
+ WP --> WF
108
+ WF --> FR
109
+ WF -->|"HTTP POST"| ExtURL["External URL<br/>(Zapier, etc.)"]
98
110
  WEP --> Models
99
111
  Client --> SES
100
112
  SNS -->|"HTTP POST"| WC
@@ -105,6 +117,7 @@ graph TB
105
117
  style Engine fill:#f0f4ff,stroke:#3366cc
106
118
  style AWS fill:#fff3e0,stroke:#ff9800
107
119
  style Host fill:#e8f5e9,stroke:#4caf50
120
+ style ExtURL fill:#fce4ec,stroke:#e91e63
108
121
  ```
109
122
 
110
123
  ## Installation
@@ -251,6 +264,113 @@ Enable in production:
251
264
  c.verify_sns_signature = Rails.env.production?
252
265
  ```
253
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 |
317
+ |---|---|
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
+ ```
341
+
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
+ ```
373
+
254
374
  ## Development
255
375
 
256
376
  ### Prerequisites
@@ -300,7 +420,7 @@ The engine creates three tables (prefixed `ses_dashboard_`):
300
420
 
301
421
  | Table | Key Columns |
302
422
  |---|---|
303
- | `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description` |
423
+ | `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description`, `webhook_forwards` (JSON) |
304
424
  | `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
305
425
  | `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
306
426
 
@@ -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
  })();
@@ -217,10 +217,75 @@ tr:hover td { background: var(--color-bg); }
217
217
  /* ── Token display ───────────────────────────────────────── */
218
218
  .token-display { font-family: monospace; font-size: .8125rem; background: var(--color-bg); padding: .25rem .5rem; border-radius: .25rem; border: 1px solid var(--color-border); }
219
219
 
220
+ /* ── Webhook Forwards builder ────────────────────────────── */
221
+ .wf-target {
222
+ margin-bottom: .75rem;
223
+ padding: 1rem;
224
+ }
225
+
226
+ .wf-target-header {
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: space-between;
230
+ margin-bottom: .75rem;
231
+ }
232
+
233
+ .wf-target-title {
234
+ font-weight: 600;
235
+ font-size: .875rem;
236
+ }
237
+
238
+ .wf-rules {
239
+ display: flex;
240
+ flex-direction: column;
241
+ gap: .5rem;
242
+ margin-bottom: .5rem;
243
+ }
244
+
245
+ .wf-rule {
246
+ display: flex;
247
+ align-items: flex-start;
248
+ gap: .5rem;
249
+ }
250
+
251
+ .wf-rule .wf-field { flex: 0 0 140px; }
252
+ .wf-rule .wf-operator { flex: 0 0 130px; }
253
+ .wf-rule .wf-value-wrap { flex: 1; min-width: 0; }
254
+ .wf-rule .wf-value { width: 100%; }
255
+ .wf-rule .btn { flex-shrink: 0; align-self: flex-start; margin-top: 1px; }
256
+
257
+ .wf-checkboxes {
258
+ display: flex;
259
+ flex-wrap: wrap;
260
+ gap: .15rem .75rem;
261
+ padding: .4rem .25rem;
262
+ }
263
+
264
+ .wf-checkbox-label {
265
+ display: inline-flex;
266
+ align-items: center;
267
+ gap: .3rem;
268
+ font-size: .8125rem;
269
+ cursor: pointer;
270
+ white-space: nowrap;
271
+ }
272
+
273
+ .form-hint {
274
+ display: block;
275
+ font-size: .8125rem;
276
+ color: var(--color-text-muted);
277
+ margin-top: .25rem;
278
+ }
279
+
280
+ #wf-add-target { margin-top: .25rem; }
281
+
220
282
  /* ── Responsive ──────────────────────────────────────────── */
221
283
  @media (max-width: 640px) {
222
284
  .stat-grid { grid-template-columns: 1fr 1fr; }
223
285
  .page-header { flex-direction: column; align-items: flex-start; gap: .5rem; }
224
286
  .filter-bar { flex-direction: column; }
225
287
  .filter-bar .form-control { width: 100%; }
288
+ .wf-rule { flex-wrap: wrap; }
289
+ .wf-rule .wf-field, .wf-rule .wf-operator { flex: 1 1 45%; }
290
+ .wf-rule .wf-value-wrap { flex: 1 1 100%; }
226
291
  }
@@ -55,7 +55,7 @@ module SesDashboard
55
55
  end
56
56
 
57
57
  def project_params
58
- params.require(:project).permit(:name, :description)
58
+ params.require(:project).permit(:name, :description, :webhook_forwards_text)
59
59
  end
60
60
 
61
61
  def parse_date(str)
@@ -22,6 +22,7 @@ module SesDashboard
22
22
  confirm_subscription(result.subscribe_url)
23
23
  when :process_event
24
24
  WebhookEventPersistor.new(project, result).persist
25
+ WebhookForwarder.new(project, result).forward
25
26
  end
26
27
 
27
28
  head :ok
@@ -6,15 +6,63 @@ module SesDashboard
6
6
 
7
7
  validates :name, presence: true
8
8
  validates :token, presence: true, uniqueness: true
9
+ validate :webhook_forwards_must_be_valid_json
9
10
 
10
11
  before_validation :generate_token, on: :create
11
12
 
12
13
  scope :ordered, -> { order(:name) }
13
14
 
15
+ # Manual JSON serialization for the webhook_forwards column.
16
+ # Avoids Rails serialize API which changed between 7.0 (positional)
17
+ # and 8.0 (keyword-only coder:).
18
+ def webhook_forwards
19
+ raw = read_attribute(:webhook_forwards)
20
+ return [] if raw.blank?
21
+ return raw if raw.is_a?(Array)
22
+
23
+ JSON.parse(raw)
24
+ rescue JSON::ParserError
25
+ []
26
+ end
27
+
28
+ def webhook_forwards=(value)
29
+ write_attribute(:webhook_forwards, value.is_a?(String) ? value : Array(value).to_json)
30
+ end
31
+
32
+ # Virtual accessor for editing webhook_forwards as a JSON string in forms.
33
+ def webhook_forwards_text
34
+ forwards = Array(webhook_forwards)
35
+ forwards.empty? ? "" : JSON.pretty_generate(forwards)
36
+ end
37
+
38
+ def webhook_forwards_text=(value)
39
+ if value.blank?
40
+ self.webhook_forwards = []
41
+ return
42
+ end
43
+
44
+ parsed = JSON.parse(value)
45
+ self.webhook_forwards = Array(parsed)
46
+ rescue JSON::ParserError
47
+ @webhook_forwards_invalid = true
48
+ end
49
+
50
+ # Returns the effective forwards: project-level if configured, else global config.
51
+ def effective_webhook_forwards
52
+ project_level = Array(webhook_forwards).select { |f| (f[:url] || f["url"]).present? }
53
+ return project_level if project_level.any?
54
+
55
+ Array(SesDashboard.configuration&.webhook_forwards)
56
+ end
57
+
14
58
  private
15
59
 
16
60
  def generate_token
17
61
  self.token ||= SecureRandom.hex(16)
18
62
  end
63
+
64
+ def webhook_forwards_must_be_valid_json
65
+ errors.add(:webhook_forwards, "is not valid JSON") if @webhook_forwards_invalid
66
+ end
19
67
  end
20
68
  end
@@ -19,6 +19,22 @@
19
19
  <%= f.text_area :description, class: "form-control", placeholder: "Optional description" %>
20
20
  </div>
21
21
 
22
+ <div class="form-group">
23
+ <label class="form-label">Webhook Forwards</label>
24
+ <small class="form-hint" style="margin-bottom:.5rem;display:block;">
25
+ Forward events to external URLs (e.g. Zapier). Add rules to filter which events are forwarded.
26
+ All rules must match. Leave empty to use global defaults.
27
+ </small>
28
+
29
+ <%= f.hidden_field :webhook_forwards_text, id: "webhook-forwards-json" %>
30
+
31
+ <div id="wf-targets"></div>
32
+
33
+ <button type="button" class="btn btn-outline btn-sm" id="wf-add-target">+ Add Forward Target</button>
34
+
35
+ <script type="application/json" id="wf-initial-data"><%= raw project.webhook_forwards_text.html_safe %></script>
36
+ </div>
37
+
22
38
  <%= f.submit class: "btn btn-primary" %>
23
39
  <%= link_to "Cancel", projects_path, class: "btn btn-outline" %>
24
40
  <% end %>
@@ -0,0 +1,11 @@
1
+ _migration = begin
2
+ ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"]
3
+ rescue ArgumentError
4
+ ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.0"]
5
+ end
6
+
7
+ class AddWebhookForwardsToSesDashboardProjects < _migration
8
+ def change
9
+ add_column :ses_dashboard_projects, :webhook_forwards, :text
10
+ end
11
+ end
@@ -0,0 +1,72 @@
1
+ module SesDashboard
2
+ # Evaluates a single forwarding rule against a WebhookProcessor::Result.
3
+ #
4
+ # A rule is a Hash with three keys:
5
+ # "field" — which Result attribute to test
6
+ # "operator" — how to compare
7
+ # "value" — what to compare against
8
+ #
9
+ # Supported fields:
10
+ # "event_type" — string ("bounce", "delivery", "complaint", …)
11
+ # "source" — string (From: address)
12
+ # "destination" — array (To: addresses — rule passes if ANY element matches)
13
+ # "subject" — string (email subject)
14
+ #
15
+ # Supported operators:
16
+ # "in" — field value is included in the given array
17
+ # "not_in" — field value is NOT included in the given array
18
+ # "eq" — exact string equality
19
+ # "not_eq" — string inequality
20
+ # "starts_with" — prefix match (for arrays: any element matches)
21
+ # "ends_with" — suffix match (for arrays: any element matches)
22
+ # "contains" — substring match (for arrays: any element matches)
23
+ #
24
+ # New fields/operators can be added by extending the private methods below.
25
+ #
26
+ class ForwardRule
27
+ def initialize(rule_hash)
28
+ @field = (rule_hash["field"] || rule_hash[:field]).to_s
29
+ @operator = (rule_hash["operator"] || rule_hash[:operator]).to_s
30
+ @value = rule_hash["value"] || rule_hash[:value]
31
+ end
32
+
33
+ def match?(result)
34
+ field_value = extract_field(result)
35
+ evaluate(field_value)
36
+ end
37
+
38
+ private
39
+
40
+ def extract_field(result)
41
+ case @field
42
+ when "event_type" then result.event_type
43
+ when "source" then result.source
44
+ when "destination" then result.destination
45
+ when "subject" then result.subject
46
+ end
47
+ end
48
+
49
+ def evaluate(field_value)
50
+ case @operator
51
+ when "in" then Array(@value).include?(field_value)
52
+ when "not_in" then !Array(@value).include?(field_value)
53
+ when "eq" then any_string_match(field_value) { |v| v == @value.to_s }
54
+ when "not_eq" then !any_string_match(field_value) { |v| v == @value.to_s }
55
+ when "starts_with" then any_string_match(field_value) { |v| v.start_with?(@value.to_s) }
56
+ when "ends_with" then any_string_match(field_value) { |v| v.end_with?(@value.to_s) }
57
+ when "contains" then any_string_match(field_value) { |v| v.include?(@value.to_s) }
58
+ else false
59
+ end
60
+ end
61
+
62
+ # For array fields (e.g. destination), passes if ANY element matches.
63
+ # For scalar fields, tests the single value.
64
+ def any_string_match(field_value, &block)
65
+ if field_value.is_a?(Array)
66
+ field_value.any? { |v| block.call(v.to_s) }
67
+ else
68
+ block.call(field_value.to_s)
69
+ end
70
+ end
71
+ end
72
+ end
@@ -1,3 +1,3 @@
1
1
  module SesDashboard
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
3
3
  end
@@ -0,0 +1,105 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ module SesDashboard
6
+ # Forwards processed SES events to external webhook URLs based on configurable rules.
7
+ #
8
+ # Rules are resolved per-project (see Project#effective_webhook_forwards), with a
9
+ # global fallback from SesDashboard.configuration.webhook_forwards.
10
+ #
11
+ # Each forward entry supports a `rules` array — all rules must match (AND logic).
12
+ # If no rules are specified, every event is forwarded.
13
+ #
14
+ # [
15
+ # {
16
+ # "url": "https://hooks.zapier.com/hooks/catch/abc/xyz/",
17
+ # "rules": [
18
+ # { "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] },
19
+ # { "field": "source", "operator": "ends_with", "value": "@myapp.com" }
20
+ # ]
21
+ # }
22
+ # ]
23
+ #
24
+ # Legacy shorthand `event_types` is still supported and auto-converted to a rule:
25
+ # { "url": "...", "event_types": ["bounce"] }
26
+ # ⟶ rules: [{ field: "event_type", operator: "in", value: ["bounce"] }]
27
+ #
28
+ class WebhookForwarder
29
+ OPEN_TIMEOUT = 5 # seconds
30
+ READ_TIMEOUT = 10 # seconds
31
+
32
+ def initialize(project, result)
33
+ @project = project
34
+ @result = result
35
+ end
36
+
37
+ def forward
38
+ forwards = @project.effective_webhook_forwards
39
+ return if forwards.empty?
40
+
41
+ forwards.each do |config|
42
+ url = config[:url] || config["url"]
43
+ next if url.blank?
44
+ next unless matches_rules?(config)
45
+
46
+ post_to(url)
47
+ rescue => e
48
+ log_warn("Forward to #{url} failed: #{e.message}")
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def matches_rules?(config)
55
+ rules = resolve_rules(config)
56
+ return true if rules.empty?
57
+
58
+ rules.all? { |rule| ForwardRule.new(rule).match?(@result) }
59
+ end
60
+
61
+ # Supports both the new `rules` format and the legacy `event_types` shorthand.
62
+ def resolve_rules(config)
63
+ rules = Array(config["rules"] || config[:rules])
64
+ return rules if rules.any?
65
+
66
+ event_types = Array(config["event_types"] || config[:event_types])
67
+ return [] if event_types.empty?
68
+
69
+ [{ "field" => "event_type", "operator" => "in", "value" => event_types }]
70
+ end
71
+
72
+ def post_to(url)
73
+ uri = URI(url)
74
+ http = Net::HTTP.new(uri.host, uri.port)
75
+ http.use_ssl = uri.scheme == "https"
76
+ http.open_timeout = OPEN_TIMEOUT
77
+ http.read_timeout = READ_TIMEOUT
78
+
79
+ req = Net::HTTP::Post.new(uri.request_uri)
80
+ req["Content-Type"] = "application/json"
81
+ req.body = build_payload
82
+
83
+ response = http.request(req)
84
+ unless response.is_a?(Net::HTTPSuccess)
85
+ log_warn("Forward to #{url} returned HTTP #{response.code}")
86
+ end
87
+ end
88
+
89
+ def build_payload
90
+ {
91
+ event_type: @result.event_type,
92
+ message_id: @result.message_id,
93
+ source: @result.source,
94
+ destination: @result.destination,
95
+ subject: @result.subject,
96
+ occurred_at: @result.occurred_at&.iso8601,
97
+ raw: @result.raw_payload
98
+ }.to_json
99
+ end
100
+
101
+ def log_warn(msg)
102
+ Rails.logger.warn("[SesDashboard] #{msg}") if defined?(Rails)
103
+ end
104
+ end
105
+ end
data/lib/ses_dashboard.rb CHANGED
@@ -37,6 +37,15 @@ module SesDashboard
37
37
  attr_accessor :cloudflare_team_domain # e.g. "myteam.cloudflareaccess.com"
38
38
  attr_accessor :cloudflare_aud # JWT audience (your CF application AUD)
39
39
 
40
+ # Webhook forwarding — forward specific event types to external URLs (e.g. Zapier)
41
+ # Format: array of hashes with :url and :event_types keys
42
+ # Example:
43
+ # c.webhook_forwards = [
44
+ # { url: "https://hooks.zapier.com/...", event_types: ["bounce", "complaint"] }
45
+ # ]
46
+ # Omit :event_types (or set to []) to forward all event types.
47
+ attr_accessor :webhook_forwards
48
+
40
49
  def initialize
41
50
  @aws_region = ENV.fetch("AWS_REGION", "us-east-1")
42
51
  @aws_access_key_id = nil
@@ -50,6 +59,7 @@ module SesDashboard
50
59
  @verify_sns_signature = false
51
60
  @cloudflare_team_domain = nil
52
61
  @cloudflare_aud = nil
62
+ @webhook_forwards = []
53
63
  end
54
64
  end
55
65
  end
@@ -57,6 +67,8 @@ end
57
67
  require_relative "ses_dashboard/version"
58
68
  require_relative "ses_dashboard/client"
59
69
  require_relative "ses_dashboard/webhook_processor"
70
+ require_relative "ses_dashboard/forward_rule"
71
+ require_relative "ses_dashboard/webhook_forwarder"
60
72
  require_relative "ses_dashboard/sns_signature_verifier"
61
73
  require_relative "ses_dashboard/stats_aggregator"
62
74
  require_relative "ses_dashboard/paginatable"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ses-dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - antodoms
@@ -331,6 +331,7 @@ files:
331
331
  - db/migrate/20240101000001_create_ses_dashboard_projects.rb
332
332
  - db/migrate/20240101000002_create_ses_dashboard_emails.rb
333
333
  - db/migrate/20240101000003_create_ses_dashboard_email_events.rb
334
+ - db/migrate/20240101000004_add_webhook_forwards_to_ses_dashboard_projects.rb
334
335
  - docker-compose.yml
335
336
  - lib/ses/dashboard.rb
336
337
  - lib/ses_dashboard.rb
@@ -339,10 +340,12 @@ files:
339
340
  - lib/ses_dashboard/auth/devise_adapter.rb
340
341
  - lib/ses_dashboard/client.rb
341
342
  - lib/ses_dashboard/engine.rb
343
+ - lib/ses_dashboard/forward_rule.rb
342
344
  - lib/ses_dashboard/paginatable.rb
343
345
  - lib/ses_dashboard/sns_signature_verifier.rb
344
346
  - lib/ses_dashboard/stats_aggregator.rb
345
347
  - lib/ses_dashboard/version.rb
348
+ - lib/ses_dashboard/webhook_forwarder.rb
346
349
  - lib/ses_dashboard/webhook_processor.rb
347
350
  homepage: https://github.com/antodoms/ses_dashboard
348
351
  licenses:
@@ -358,14 +361,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
358
361
  requirements:
359
362
  - - ">="
360
363
  - !ruby/object:Gem::Version
361
- version: '3.0'
364
+ version: '3.2'
362
365
  required_rubygems_version: !ruby/object:Gem::Requirement
363
366
  requirements:
364
367
  - - ">="
365
368
  - !ruby/object:Gem::Version
366
369
  version: '0'
367
370
  requirements: []
368
- rubygems_version: 4.0.6
371
+ rubygems_version: 4.0.10
369
372
  specification_version: 4
370
373
  summary: SES dashboard gem with pluggable authentication and AWS SES data fetching.
371
374
  test_files: []