ses-dashboard 0.4.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 +4 -4
- data/README.md +122 -2
- data/app/assets/javascripts/ses_dashboard/application.js +263 -0
- data/app/assets/stylesheets/ses_dashboard/application.css +65 -0
- data/app/controllers/ses_dashboard/projects_controller.rb +1 -1
- data/app/controllers/ses_dashboard/webhooks_controller.rb +1 -0
- data/app/models/ses_dashboard/project.rb +48 -0
- data/app/views/ses_dashboard/projects/_form.html.erb +16 -0
- data/db/migrate/20240101000004_add_webhook_forwards_to_ses_dashboard_projects.rb +11 -0
- data/lib/ses_dashboard/forward_rule.rb +72 -0
- data/lib/ses_dashboard/version.rb +1 -1
- data/lib/ses_dashboard/webhook_forwarder.rb +105 -0
- data/lib/ses_dashboard.rb +12 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9efac9cf44528ae2052092bfcef569f312d9da609f23039459515ddc54a67072
|
|
4
|
+
data.tar.gz: 387467a34a67ec49a15241222b6a9dc077abcda5c545c872737e152b22d82905
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 771334e8e2d8d0459d1c462022a69608958753d67845e5100bea2365fbfc9e47e1bc9f3c49e5befd804e456a5de22b3b0c23f98f6a86fe3cf4d223222eac9dbd
|
|
7
|
+
data.tar.gz: 922d8c07c25a5beac7beda1ec6bcecbb483c852f2bbe031aa65003ff181982c22a7b052c8d5e0efb5cdfda99d3339a1ccd113fc3bc4a2481dbe55ac62b656ea7
|
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
|
+

|
|
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
|
}
|
|
@@ -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
|
|
@@ -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
|
+
version: 0.5.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:
|