fuik 0.11.0 → 0.12.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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -1
  3. data/app/assets/images/fuik/icons/46elks.jpg +0 -0
  4. data/app/assets/images/fuik/icons/lettermint.jpg +0 -0
  5. data/app/assets/images/fuik/icons/smtp2go.jpg +0 -0
  6. data/app/assets/javascript/attractivejs-0.12.0.min.js +1 -0
  7. data/app/assets/stylesheets/fuik/application.css +59 -0
  8. data/app/controllers/fuik/events_controller.rb +1 -1
  9. data/app/controllers/fuik/webhooks_controller.rb +20 -3
  10. data/app/jobs/fuik/webhook_processing_job.rb +7 -0
  11. data/app/models/fuik/webhook_event/filterable.rb +26 -0
  12. data/app/models/fuik/webhook_event.rb +2 -0
  13. data/app/views/fuik/events/_filters.html.erb +29 -0
  14. data/app/views/fuik/events/index.html.erb +2 -0
  15. data/app/views/layouts/fuik/application.html.erb +1 -1
  16. data/lib/fuik/version.rb +1 -1
  17. data/lib/generators/fuik/provider/templates/lettermint/base.rb.tt +32 -0
  18. data/lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt +16 -0
  19. data/lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt +16 -0
  20. data/lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt +16 -0
  21. data/lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt +15 -0
  22. data/lib/generators/fuik/provider/templates/smtp2go/base.rb.tt +4 -0
  23. data/lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt +16 -0
  24. data/lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt +14 -0
  25. data/lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt +13 -0
  26. data/lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt +13 -0
  27. metadata +19 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b15633f53ffe1c5c7dad94fb1d5151a8c2bce010318854cc50a4e3afbe48c828
4
- data.tar.gz: 0ba925e04a5271e77a8a8ed4c80bbda7a4a00a5d9d87c6b3b304104519988a29
3
+ metadata.gz: 4a653582dac2c822bb4496a89d0dbd12cd9b96a082bdeed11c209793df27de21
4
+ data.tar.gz: 44070a1620d3d867cc5a400e7f61bbd58bebec6e713407d6da6a65d8d2d2037b
5
5
  SHA512:
6
- metadata.gz: 5c7c0c9ee432bd0702969c6238c0ceea4200da1370350a4d88337f14252bc6f4bce0990a0c80e49dd862de9f25459c4cd1a1f5b914e41de1b021e0ba3b21c255
7
- data.tar.gz: 93e200fcc81563e5f6a9699067c6543c4e4b26af42f1504dff84aa0a2e59ce89efe2fb2a571dde5fd2bc298bded45b2c9b332d44c1deb79fea22ce4f0130248b
6
+ metadata.gz: c870f47e09396993506e96851c491d0e715ddf87d964554aba212e83927a2e9370c75b601cce24f8b4a0653b1b8cfe16f3825fb916048fa9828c05e95ab8d49a
7
+ data.tar.gz: 9ce68f8cb2d9aa7af1aa3cfb6a394a1a26cc165ed6dc58320fbfc0b266d219e839d0a29a352164fadce614ff75579dcb8041077d553cdbd45cfe6424d709125e
data/README.md CHANGED
@@ -6,7 +6,6 @@ Fuik (Dutch for fish trap) is a Rails engine that catches and stores webhooks fr
6
6
 
7
7
  <img alt="Fuik admin interface" src="https://raw.githubusercontent.com/Rails-Designer/fuik/HEAD/.github/docs/webhooks-index.jpg" style="max-width: 100%;">
8
8
 
9
-
10
9
  **Sponsored By [Rails Designer](https://railsdesigner.com/)**
11
10
 
12
11
  <a href="https://railsdesigner.com/" target="_blank">
@@ -60,6 +59,13 @@ Visit `/webhooks` to see all received webhooks. Click any event to view all the
60
59
 
61
60
  ⚠️ The `/webhooks` path is by default not protected. Easiest is to set `Fuik::Engine.config.events_controller_parent` to a controller that requires authentication.
62
61
 
62
+ ### Dashboard features
63
+
64
+ * **Copy payload as JSON**: click a button, payload is in your clipboard
65
+ * **Download payload as JSON file**: keep it for testing, debugging or throw it at your LLM agent, bot or colleague
66
+ * **Add `.json` to any URL**: get the raw payload without the UI
67
+ * **Click any key to get the Ruby accessor path**: click `product_id` (as seen in the screenshot above) and get `payload["line_items"][0]["product_id"]` (say what? 🤯)
68
+
63
69
 
64
70
  ### Add business logic
65
71
 
@@ -0,0 +1 @@
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Attractive=e()}(this,(function(){"use strict";class t{static enabled=!1;static prefix="🧲 ";static log(...t){this.enabled&&console.log(this.prefix,...t)}static warn(...t){this.enabled&&console.warn(this.prefix,...t)}static error(...t){this.enabled&&console.error(this.prefix,...t)}static throw(t){throw new Error(`${this.prefix}${t}`)}}class e{#t;constructor(e){t.log("Events with actions:",Object.keys(e)),this.#t=e}process(t,{on:e,using:s}){e&&this.#e(e.dataset.action).some((r=>!1===this.#s(r,{for:t,on:e,using:s})))}#e(t){return t.split(" ").filter((t=>t))}#s(e,{for:s,on:r,using:i}){if(t.log("Process action for",s.type,"on",r,"…"),e.includes(":")){const[t,r]=e.split(":");if(s.type!==r)return;e=t}else if(e.includes("->")){const[t,r]=e.split("->");if((t.includes("@")?t.split("@")[1]:t)!==s.type)return;e=r}else if(s.type!==i)return;return t.log("…","processed action for",s.type,"on",r),this.#r(e,{on:r,for:s})}#r(e,{on:s,for:r}){t.log("Execute action",e,"on",s,"…");const i=e.split("#"),[n,a,o]=i,c=this.#t[n]?n:a??e;if("function"!=typeof this.#t[c])return;const l=this.#t[n]?i.slice(1).join("#"):o??null,u=this.#t[c](s,{value:l,target:s.dataset.target,targets:s.dataset.targets});return!1===u&&r&&r.preventDefault(),t.log("…","executed action",e,"on",s),u}}var s=new class{identify({by:t}){return t.split(" ").filter((t=>t.includes("->"))).map((t=>t.split("->")[0]))}getDefault({from:t}){const e=t.tagName.toLowerCase(),s="input"===e,r=s?t.type||"text":null;return s?this.#i.input[r]||this.#i.input.default:this.#i[e]||this.#i.default}#i={a:"click",button:"click",input:{checkbox:"change",radio:"change",submit:"click",button:"click",reset:"click",default:"input"},select:"change",textarea:"input",form:"submit",default:"click"}};class r{#n;#a;#o;constructor(t,e=null){this.#n=t,this.#a=e}start(t){if(window.MutationObserver)return this.#o=new MutationObserver((e=>{const s=new Set,r=new Set;e.forEach((e=>this.#c(e,{for:t,elements:{added:s,removed:r}}))),s.forEach((t=>this.#n(t))),this.#a&&r.forEach((t=>this.#a(t)))})),this.#o.observe(document.documentElement,{childList:!0,subtree:!0}),this}stop(){return this.#o&&this.#o.disconnect(),this}#c(t,{for:e,elements:{added:s,removed:r}}){"childList"===t.type&&(t.addedNodes.forEach((t=>{this.#l(t,{for:e,and:s})})),t.removedNodes.forEach((t=>{this.#l(t,{for:e,and:r})})))}#l(t,{for:e,and:s}){t.nodeType===Node.ELEMENT_NODE&&(t.matches&&t.matches(e)&&s.add(t),t.querySelectorAll&&t.querySelectorAll(e).forEach((t=>s.add(t))))}}var i=new class{#u={mounted:(t,e)=>{e()},now:(t,e)=>{e()},whenVisible:(t,e)=>{const s=new IntersectionObserver((t=>{t.forEach((t=>{t.isIntersecting&&(e(),s.disconnect())}))}));s.observe(t)}};setup({for:t,on:e,trigger:s}){const r=this.#u[t];return!!r&&(r(e,s),!0)}};class n{constructor(t,e={}){if(!t)throw new Error("Current element is required");this.currentElement=t,this.target=e.target,this.targetsSelector=e.targets,this.options=e}get targets(){if(this.targetsSelector)return Array.from(document.querySelectorAll(this.targetsSelector));if(this.target){const t=document.getElementById(this.target);return t?[t]:[]}return[this.currentElement]}cycledValue(t,e){const s=Array.isArray(e)?e:e.split(",").map((t=>t.trim())),r=s.indexOf(t);return s[(r+1)%s.length]}}class a extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>t.hasAttribute(this.attribute)?t.removeAttribute(this.attribute):t.setAttribute(this.attribute,this.value||"")))}cycle(){this.value&&this.targets.forEach((t=>this.#h(t)))}add(){this.attribute&&this.targets.forEach((t=>t.setAttribute(this.attribute,this.value||"")))}remove(){this.attribute&&this.targets.forEach((t=>t.removeAttribute(this.attribute)))}#h(t){const e=this.cycledValue(t.getAttribute(this.attribute),this.value);t.setAttribute(this.attribute,e)}}const o=t=>(e,s={})=>new a(e,s)[t]();var c={toggleAttribute:o("toggle"),cycleAttribute:o("cycle"),addAttribute:o("add"),removeAttribute:o("remove")};const l=t=>t?.split(",").map((t=>t.trim())).filter(Boolean)??[];class u extends n{constructor(t,e={}){super(t,e),this.value=l(e.value)}toggle(){this.value&&this.targets.forEach((t=>this.#d({forEach:t})))}cycle(){this.value&&0!==this.value.length&&this.targets.forEach((t=>this.#f(t)))}add(){this.value&&this.targets.forEach((t=>t.classList.add(...this.value)))}remove(){this.value&&this.targets.forEach((t=>t.classList.remove(...this.value)))}#d({forEach:t}){this.value.forEach((e=>t.classList.toggle(e)))}#f(t){const e=this.value.find((e=>t.classList.contains(e)))||"",s=this.cycledValue(e,this.value);t.classList.remove(...this.value),t.classList.add(s)}}const h=t=>(e,s={})=>new u(e,s)[t]();var d={toggleClass:h("toggle"),cycleClass:h("cycle"),addClass:h("add"),removeClass:h("remove")};let f;const g=(t,e)=>{clearTimeout(f),f=setTimeout(t,e)};class v extends n{constructor(t,e={}){super(t,e),this.value=e.value}async copy(){const t=this.value||(this.targets[0]?.value??this.targets[0]?.textContent);if(void 0!==t)try{await navigator.clipboard.writeText(t),this.#g(!0)}catch(t){this.#g(!1)}}#g(t){const e=parseInt(this.currentElement.dataset.copyDelay);this.targets.forEach((e=>e.setAttribute(this.#v,t))),e&&g((()=>this.targets.forEach((t=>t.removeAttribute(this.#v)))),e)}get#v(){return"data-copy-success"}}var m,p={copy:(m="copy",(t,e={})=>new v(t,e)[m]())};class b extends n{confirm(){const t=this.currentElement.dataset.confirmMessage||"Are you sure?",e=window.confirm(t);return this.#g(e),e}#g(t){this.currentElement.setAttribute("data-confirm-success",t);const e=this.currentElement.dataset.confirmDuration;e&&g((()=>this.currentElement.removeAttribute("data-confirm-success")),parseInt(e))}}var E={confirm:(t=>(e,s={})=>new b(e,s)[t]())("confirm")};class w extends n{constructor(t,e={}){super(t,e);const[s,r]=e.value.split("=");this.attribute=s,this.value=r}toggle(){this.attribute&&this.targets.forEach((t=>{this.attribute in t.dataset?delete t.dataset[this.attribute]:t.dataset[this.attribute]=this.value||""}))}cycle(){this.value&&this.targets.forEach((t=>this.#m(t)))}add(){this.attribute&&this.targets.forEach((t=>{t.dataset[this.attribute]=this.value||""}))}remove(){this.attribute&&this.targets.forEach((t=>delete t.dataset[this.attribute]))}#m(t){const e=this.cycledValue(t.dataset[this.attribute],this.value);t.dataset[this.attribute]=e}}const y=t=>(e,s={})=>new w(e,s)[t]();var A={toggleDataAttribute:y("toggle"),cycleDataAttribute:y("cycle"),addDataAttribute:y("add"),removeDataAttribute:y("remove")};class L extends n{open(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.show()))}openModal(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.showModal()))}close(){this.targets.forEach((t=>t instanceof HTMLDialogElement&&t.close()))}}const T=t=>(e,s={})=>{new L(e,s)[t]()};var x={open:T("open"),openModal:T("openModal"),close:T("close")};class M extends n{add(){const t=this.targets[0];if(!t)return;const e=this.currentElement.dataset.addSource;if(!e)return;const s=document.querySelector(e);if(!s)return;const r=this.#p(s),i=this.currentElement.dataset.addAt||"beforeend";t.insertAdjacentElement(i,r)}remove(){const t=parseInt(this.currentElement.dataset.removeDelay),e=()=>{this.targets.forEach((t=>t.remove()))};t?setTimeout(e,t):e()}#p(t){return"TEMPLATE"===t.tagName?t.content.cloneNode(!0).firstElementChild:t.cloneNode(!0)}}const S=t=>(e,s={})=>new M(e,s)[t]();var k={add:S("add"),remove:S("remove")};class N extends n{requestSubmit(){const t=parseInt(this.currentElement.dataset.submitDelay)||0,e=()=>this.targets.forEach((t=>t instanceof HTMLFormElement&&t.requestSubmit()));t?g(e,t):e()}reset(){this.targets.forEach((t=>t instanceof HTMLFormElement&&t.reset()))}}const F=t=>(e,s={})=>{new N(e,s)[t]()};var D={submit:F("requestSubmit"),reset:F("reset")};class q extends n{constructor(t,e={}){super(t,e),this.classNames=l(e.value),this.justOnce="intersect-once"===e.actionName}start(){if(0===this.classNames.length)return;const t=new IntersectionObserver((e=>{e.forEach((e=>{e.isIntersecting?this.#b(t,e.target):this.#E()}))}));t.observe(this.currentElement)}#b(t,e){this.targets.forEach((t=>t.classList.add(...this.classNames))),this.justOnce&&t.unobserve(e)}#E(){this.justOnce||this.targets.forEach((t=>t.classList.remove(...this.classNames)))}}const C=t=>(e,s={})=>new q(e,{...s,actionName:t}).start();var O={"intersect-once":C("intersect-once"),"intersect-toggle":C("intersect-toggle")};class I extends n{reload(){this.targets.forEach((t=>{this.#w(t)?t.reload():window.location.reload()}))}#w(t){return"TURBO-FRAME"===t.tagName&&"function"==typeof t.reload}}const H=t=>(e,s={})=>new I(e,s)[t]();var V={reload:H("reload"),refresh:H("reload")};class j{static get(){const t=document.querySelector('meta[name="csrf-token"]')?.content;return t||null}}class $ extends n{constructor(t,e={}){super(t,e),this.value=e.value}async get(){if(this.value){this.#y({with:this.value})&&console.warn(`Cross-origin request to: ${this.value}. Missing the correct CORS headers.`),this.#g("busy");try{const t=await fetch(this.value,{method:"GET"});if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);const e=await t.text();return this.targets.forEach((t=>{t.innerHTML=e})),this.#g("success"),t}catch(t){throw console.error("GET request failed:",t),this.#g("error"),t}}else console.warn("No URL provided in the action value")}post(){return this.#A("POST")}patch(){return this.#A("PATCH")}put(){return this.#A("PUT")}#y({with:t}){try{return new URL(t,window.location.href).origin!==window.location.origin}catch{return console.error("Invalid URL:",t),!1}}#g(t){const e=this.currentElement.dataset.requestDuration;this.targets.forEach((e=>{"busy"===t?(e.setAttribute("data-request-busy","true"),e.removeAttribute("data-request-success")):(e.removeAttribute("data-request-busy"),e.setAttribute("data-request-success","success"===t))})),e&&"busy"!==t&&g((()=>{this.targets.forEach((t=>t.removeAttribute("data-request-success")))}),parseInt(e))}#A(t){if(this.value)return this.#g("busy"),fetch(this.value,{method:t,headers:{"Content-Type":"application/json","X-CSRF-Token":j.get()},body:JSON.stringify(this.#L)}).then((t=>{if(!t.ok)throw new Error(`HTTP error! status: ${t.status}`);return this.#g("success"),t})).catch((e=>{throw console.error(`${t} request failed:`,e),this.#g("error"),e}));console.warn("No URL provided in the action value")}get#L(){const t={},e=this.targets[0];if(e instanceof HTMLFormElement){const s=new FormData(e);for(const[e,r]of s.entries())t[e]=r;return t}if(this.#T){const e=this.currentElement.name,s=this.currentElement.value;t[e]=s}return t}get#T(){return this.currentElement instanceof HTMLInputElement||this.currentElement instanceof HTMLSelectElement||this.currentElement instanceof HTMLTextAreaElement&&this.currentElement.name}}const R=t=>(e,s={})=>new $(e,s)[t]();var P={get:R("get"),post:R("post"),patch:R("patch"),put:R("put")};class U extends n{constructor(t,e={}){super(t,e);const s=e.value;this.behavior=["auto","instant","smooth"].includes(s)?s:"auto"}scroll(){this.targets[0]?.scrollIntoView({behavior:this.behavior})}}var z={scrollTo:(t=>(e,s={})=>new U(e,s)[t]())("scroll")};const B={attribute:c,class:d,clipboard:p,confirm:E,dataAttribute:A,dialog:x,element:k,form:D,intersection:O,reload:V,request:P,scrollTo:z},G=(t=[])=>0===t.length?Object.values(B).reduce(((t,e)=>({...t,...e})),{}):B.reduce(((e,s)=>t[s]?{...e,...t[s]}:(console.warn(`Action “${s}” not found`),e)),{});var _=G([]);var J=new class{#x={};#M=new WeakMap;#S=new Map;#k;#N=new Map;#F;static get debug(){return t.enabled}static set debug(e){t.enabled=e}constructor(){this.#k=new e(_),this.#F=new r((t=>this.#n(t)),(t=>this.#a(t)))}activate(e={}){const{on:s=document,debug:r=!1}=e;return t.enabled=r,t.log("Initializing…"),this.element=s,this.#F.start("[data-action]"),this.element.querySelectorAll("[data-action]").forEach((t=>this.#n(t))),t.log("…initialized"),this}withActions(s=[]){return t.log("Initializing with actions",s),this.#k=new e(G(s)),this}#n(t){const e=t.dataset.action;if(!e)return;const r=e.split(" ");new Set(e.includes("->")?s.identify({by:e}):[s.getDefault({from:t})]).forEach((t=>this.#D({for:t}))),r.filter((t=>t.includes("@"))).forEach((e=>{const[s]=e.split("->"),[r,i]=s.split("@"),n="window"===r?window:document;this.#q(i,n,t)}));const n=r.filter((t=>t.includes(":"))).map((t=>t.split(":")[1]));n.forEach((e=>{i.setup({for:e,on:t,trigger:()=>this.#k.process({type:e},{on:t,using:e})})}))}#a(t){const e=this.#M.get(t);e&&(e.forEach((e=>{const s=this.#N.get(e);if(s&&(s.delete(t),0===s.size)){const[t,s]=e.split(":"),r=this.#S.get(e);("window"===t?window:document).removeEventListener(s,r),this.#S.delete(e),this.#N.delete(e)}})),this.#M.delete(t))}#D({for:e}){this.#x[e]||(this.element.addEventListener(e,(t=>this.#C(t))),t.log("Added event listener for",e,"to",this.element),this.#x[e]=!0)}#q(t,e,s){const r=`${e===window?"window":"document"}:${t}`;if(this.#M.has(s)||this.#M.set(s,new Set),this.#M.get(s).add(r),this.#N.has(r)||this.#N.set(r,new Set),this.#N.get(r).add(s),!this.#S.has(r)){const s=e=>{const s=this.#N.get(r);s&&s.forEach((s=>{this.#k.process(e,{on:s,using:t})}))};e.addEventListener(t,s),this.#S.set(r,s)}}#C(t){const e=t.target.closest("[data-action]");if(!e)return;const r=s.getDefault({from:e});this.#k.process(t,{on:e,using:r})}};const W=(t=document)=>{window.Attractive&&window.Attractive._initialized||J.activate({element:t})};return"undefined"!=typeof document&&("loading"===document.readyState?document.addEventListener("DOMContentLoaded",(()=>W())):W(),document.addEventListener("turbo:load",(()=>W()))),"undefined"!=typeof window&&(window.Attractive=J),J}));
@@ -101,6 +101,7 @@
101
101
  @layer components {
102
102
  .events {
103
103
  list-style: none;
104
+ margin-block-start: 1rem;
104
105
 
105
106
  li {
106
107
  --icon-width: 1.125rem;
@@ -163,6 +164,64 @@
163
164
  &[data-status="failed"]::before { color: oklch(60% .2 25); }
164
165
  }
165
166
 
167
+ .filters {
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 2rem;
171
+
172
+ fieldset {
173
+ border: 0;
174
+
175
+ legend {
176
+ font-size: .875rem;
177
+ font-weight: 400;
178
+ color: var(--color-text-muted);
179
+ }
180
+
181
+ input[type="radio"] {
182
+ position: absolute;
183
+ clip: rect(0, 0, 0, 0);
184
+ }
185
+
186
+ .pills {
187
+ display: inline-flex;
188
+ background: var(--color-bg);
189
+ border: 1px solid var(--color-border);
190
+ border-radius: .3rem;
191
+ overflow: clip;
192
+
193
+ .pill {
194
+ display: inline-flex;
195
+ align-items: center;
196
+ padding: .25rem .75rem;
197
+ font-size: .875rem;
198
+ font-weight: 400;
199
+ border-inline-start: 1px solid var(--color-border);
200
+ cursor: pointer;
201
+ transition: all ease-in-out 100ms;
202
+
203
+ &:first-child { border-inline-start: 0; }
204
+
205
+ &:has(input:checked),
206
+ &:hover:not(:has(input:checked)) {
207
+ background-color: var(--color-bg-hover);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ select {
214
+ padding: .4rem .75rem;
215
+ font-size: .875rem;
216
+ background: var(--color-bg);
217
+ border: 1px solid var(--color-border);
218
+ border-radius: .25rem;
219
+ cursor: pointer;
220
+ }
221
+
222
+ [type=submit] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; }
223
+ }
224
+
166
225
  button,
167
226
  .copy-button {
168
227
  padding: .125rem;
@@ -5,7 +5,7 @@ module Fuik
5
5
  layout "fuik/application"
6
6
 
7
7
  def index
8
- @webhook_events = WebhookEvent.order(created_at: :desc)
8
+ @webhook_events = WebhookEvent.filtered(params).order(created_at: :desc)
9
9
  end
10
10
 
11
11
  def show
@@ -13,11 +13,11 @@ module Fuik
13
13
  provider: params[:provider],
14
14
  event_id: event_id,
15
15
  event_type: event_type,
16
- body: request.raw_post,
16
+ body: json_body,
17
17
  headers: headers
18
18
  )
19
19
 
20
- process!(webhook_event)
20
+ process_later!(webhook_event)
21
21
 
22
22
  head :ok
23
23
  rescue Fuik::InvalidSignature
@@ -30,6 +30,12 @@ module Fuik
30
30
 
31
31
  private
32
32
 
33
+ def json_body
34
+ return request.raw_post unless url_encoded_body?
35
+
36
+ Rack::Utils.parse_nested_query(request.raw_post).to_json
37
+ end
38
+
33
39
  def verify_signature!
34
40
  return unless should_verify?
35
41
 
@@ -47,7 +53,7 @@ module Fuik
47
53
  @payload ||= begin
48
54
  return {} if request.raw_post.blank?
49
55
 
50
- JSON.parse(request.raw_post)
56
+ JSON.parse(json_body)
51
57
  rescue JSON::ParserError
52
58
  {}
53
59
  end
@@ -60,6 +66,17 @@ module Fuik
60
66
  event_class.new(webhook_event).process!
61
67
  end
62
68
 
69
+ def process_later!(webhook_event)
70
+ event_class = event_class_for(webhook_event.provider, webhook_event.event_type)
71
+ return unless event_class
72
+
73
+ WebhookProcessingJob.perform_later(event_class.name, webhook_event)
74
+ end
75
+
76
+ def url_encoded_body?
77
+ request.media_type == "application/x-www-form-urlencoded"
78
+ end
79
+
63
80
  def event_class_for(provider, event_type)
64
81
  "#{provider.camelize}::#{event_type.tr("./:-[]", "_").camelize}".safe_constantize
65
82
  end
@@ -0,0 +1,7 @@
1
+ module Fuik
2
+ class WebhookProcessingJob < ApplicationJob
3
+ def perform(event_class_name, webhook_event)
4
+ Object.const_get(event_class_name).new(webhook_event).process!
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fuik
4
+ class WebhookEvent
5
+ module Filterable
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def filtered(params)
10
+ events = all
11
+ events = events.where(status: params[:status]) if params[:status].present?
12
+ events = events.where(provider: params[:provider]) if params[:provider].present?
13
+ events
14
+ end
15
+
16
+ def options_for_select
17
+ by_provider_name.pluck(:provider).map { [it.humanize, it] }
18
+ end
19
+ end
20
+
21
+ included do
22
+ scope :by_provider_name, -> { distinct.order(:provider) }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,6 +4,8 @@ module Fuik
4
4
  class WebhookEvent < ApplicationRecord
5
5
  self.table_name = "fuik_webhook_events"
6
6
 
7
+ include Filterable
8
+
7
9
  enum :status, %w[pending processed failed].index_by(&:itself), default: "pending"
8
10
 
9
11
  validates :provider, presence: true
@@ -0,0 +1,29 @@
1
+ <%= form_tag root_path, method: :get, id: :filters do %>
2
+ <div class="filters">
3
+ <fieldset>
4
+ <legend>Provider</legend>
5
+
6
+ <%= select_tag :provider, options_for_select(Fuik::WebhookEvent.options_for_select, params[:provider]), prompt: "All", data: {action: "form#submit", target: "filters"} %>
7
+ </fieldset>
8
+
9
+ <fieldset>
10
+ <legend>Status</legend>
11
+
12
+ <span class="pills">
13
+ <%= label_tag nil, class: "pill" do %>
14
+ <%= radio_button_tag :status, "", params[:status].blank? || params[:status].empty?, data: {action: "form#submit", target: "filters"} %>
15
+ All
16
+ <% end %>
17
+
18
+ <% Fuik::WebhookEvent.statuses.keys.each do |status| %>
19
+ <%= label_tag nil, class: "pill" do %>
20
+ <%= radio_button_tag :status, status, params[:status] == status, data: {action: "form#submit", target: "filters"} %>
21
+ <%= status.capitalize %>
22
+ <% end %>
23
+ <% end %>
24
+ </span>
25
+ </fieldset>
26
+
27
+ <%= submit_tag "Filter" %>
28
+ </div>
29
+ <% end %>
@@ -1,5 +1,7 @@
1
1
  <h1>Webhooks</h1>
2
2
 
3
+ <%= render "filters" %>
4
+
3
5
  <ul class="events">
4
6
  <% @webhook_events.each do |event| %>
5
7
  <li>
@@ -8,7 +8,7 @@
8
8
  <%= yield :head %>
9
9
 
10
10
  <%= stylesheet_link_tag "fuik/application", media: "all" %>
11
- <script defer src="https://cdn.jsdelivr.net/npm/attractivejs@0.12.0"></script>
11
+ <script src="<%= asset_path("attractivejs-0.12.0.min.js") %>"></script>
12
12
  </head>
13
13
  <body>
14
14
  <main>
data/lib/fuik/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fuik
2
- VERSION = "0.11.0"
2
+ VERSION = "0.12.0"
3
3
  end
@@ -0,0 +1,32 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ def self.verify!(request)
4
+ secret = Rails.application.credentials.dig(:lettermint, :signing_secret)
5
+ signature_header = request.headers["X-Lettermint-Signature"] || ""
6
+
7
+ elements = {}
8
+ signature_header.split(",").each do |element|
9
+ key, value = element.split("=", 2)
10
+
11
+ elements[key] = value if key && value
12
+ end
13
+
14
+ timestamp = elements["t"]
15
+ signature = elements["v1"]
16
+
17
+ raise Fuik::InvalidSignature if timestamp.blank? || signature.blank?
18
+
19
+ now = Time.current.to_i
20
+ raise Fuik::InvalidSignature if (now - timestamp.to_i).abs > TOLERANCE
21
+
22
+ signed_payload = "#{timestamp}.#{request.raw_post}"
23
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
24
+
25
+ raise Fuik::InvalidSignature unless ActiveSupport::SecurityUtils.secure_compare(expected, signature)
26
+ end
27
+
28
+ private
29
+
30
+ TOLERANCE = 5.minutes
31
+ end
32
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageFailed < Base
3
+ def process!
4
+ # payload.data.recipient # email address that failed
5
+ # payload.data.reason # failure reason (e.g., "A network error occurred.")
6
+ # payload.data.response.status_code # SMTP status code
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageHardBounced < Base
3
+ def process!
4
+ # payload.data.recipient # email address that hard bounced
5
+ # payload.data.response.enhanced_status_code # SMTP enhanced status code (e.g., "5.1.1")
6
+ # payload.data.response.content # SMTP response text (e.g., "User unknown")
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class MessageSoftBounced < Base
3
+ def process!
4
+ # payload.data.recipient # email address that soft bounced
5
+ # payload.data.response.enhanced_status_code # SMTP enhanced status code (e.g., "4.2.2")
6
+ # payload.data.response.content # SMTP response text (e.g., "Mailbox full")
7
+ # payload.data.subject # email subject
8
+ # payload.data.message_id # message UUID
9
+ # payload.data.tag # message tag (or nil)
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ module <%= provider_module_name %>
2
+ class MessageSuppressed < Base
3
+ def process!
4
+ # payload.data.recipient # email address that was suppressed
5
+ # payload.data.reason # suppression reason (e.g., "hard_bounce", "spam_complaint")
6
+ # payload.data.subject # email subject
7
+ # payload.data.message_id # message UUID
8
+ # payload.data.tag # message tag (or nil)
9
+
10
+ # TODO: Add business logic
11
+
12
+ @webhook_event.processed!
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,4 @@
1
+ module <%= provider_module_name %>
2
+ class Base < Fuik::Event
3
+ end
4
+ end
@@ -0,0 +1,16 @@
1
+ module <%= provider_module_name %>
2
+ class Bounce < Base
3
+ def process!
4
+ # payload.rcpt # email address that bounced
5
+ # payload.bounce # "hard" or "soft"
6
+ # payload.host # recipient server
7
+ # payload.message # SMTP error message
8
+ # payload.subject # email subject
9
+ # payload.email_id # email identifier
10
+
11
+ # TODO: Add business logic
12
+
13
+ @webhook_event.processed!
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ module <%= provider_module_name %>
2
+ class Reject < Base
3
+ def process!
4
+ # payload.rcpt # email address that was rejected
5
+ # payload.message # rejection reason
6
+ # payload.subject # email subject
7
+ # payload.email_id # email identifier
8
+
9
+ # TODO: Add business logic
10
+
11
+ @webhook_event.processed!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ module <%= provider_module_name %>
2
+ class Spam < Base
3
+ def process!
4
+ # payload.rcpt # email address that complained
5
+ # payload.subject # email subject
6
+ # payload.email_id # email identifier
7
+
8
+ # TODO: Add business logic
9
+
10
+ @webhook_event.processed!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module <%= provider_module_name %>
2
+ class Unsubscribe < Base
3
+ def process!
4
+ # payload.rcpt # email address that unsubscribed
5
+ # payload.subject # email subject
6
+ # payload.email_id # email identifier
7
+
8
+ # TODO: Add business logic
9
+
10
+ @webhook_event.processed!
11
+ end
12
+ end
13
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fuik
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rails Designer
@@ -35,6 +35,7 @@ files:
35
35
  - MIT-LICENSE
36
36
  - README.md
37
37
  - Rakefile
38
+ - app/assets/images/fuik/icons/46elks.jpg
38
39
  - app/assets/images/fuik/icons/adyen.jpg
39
40
  - app/assets/images/fuik/icons/anthropic.jpg
40
41
  - app/assets/images/fuik/icons/apple.jpg
@@ -47,6 +48,7 @@ files:
47
48
  - app/assets/images/fuik/icons/gitlab.jpg
48
49
  - app/assets/images/fuik/icons/google.jpg
49
50
  - app/assets/images/fuik/icons/gumroad.jpg
51
+ - app/assets/images/fuik/icons/lettermint.jpg
50
52
  - app/assets/images/fuik/icons/linkedin.jpg
51
53
  - app/assets/images/fuik/icons/loops.jpg
52
54
  - app/assets/images/fuik/icons/mailgun.jpg
@@ -58,12 +60,14 @@ files:
58
60
  - app/assets/images/fuik/icons/resend.jpg
59
61
  - app/assets/images/fuik/icons/shopify.jpg
60
62
  - app/assets/images/fuik/icons/slack.jpg
63
+ - app/assets/images/fuik/icons/smtp2go.jpg
61
64
  - app/assets/images/fuik/icons/stripe.jpg
62
65
  - app/assets/images/fuik/icons/telegram.jpg
63
66
  - app/assets/images/fuik/icons/twitter.jpg
64
67
  - app/assets/images/fuik/icons/userlist.jpg
65
68
  - app/assets/images/fuik/icons/webhook.svg
66
69
  - app/assets/images/fuik/icons/zoom.jpg
70
+ - app/assets/javascript/attractivejs-0.12.0.min.js
67
71
  - app/assets/stylesheets/fuik/application.css
68
72
  - app/controllers/concerns/fuik/event_type.rb
69
73
  - app/controllers/fuik/application_controller.rb
@@ -73,10 +77,13 @@ files:
73
77
  - app/helpers/fuik/highlight_helper.rb
74
78
  - app/helpers/fuik/icon_helper.rb
75
79
  - app/jobs/fuik/application_job.rb
80
+ - app/jobs/fuik/webhook_processing_job.rb
76
81
  - app/models/fuik/application_record.rb
77
82
  - app/models/fuik/event.rb
78
83
  - app/models/fuik/webhook_event.rb
84
+ - app/models/fuik/webhook_event/filterable.rb
79
85
  - app/views/fuik/events/_copy_button.html.erb
86
+ - app/views/fuik/events/_filters.html.erb
80
87
  - app/views/fuik/events/index.html.erb
81
88
  - app/views/fuik/events/show.html.erb
82
89
  - app/views/layouts/fuik/application.html.erb
@@ -96,6 +103,11 @@ files:
96
103
  - lib/generators/fuik/provider/templates/github/installation_created.rb.tt
97
104
  - lib/generators/fuik/provider/templates/github/push.rb.tt
98
105
  - lib/generators/fuik/provider/templates/github/star_created.rb.tt
106
+ - lib/generators/fuik/provider/templates/lettermint/base.rb.tt
107
+ - lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt
108
+ - lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt
109
+ - lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt
110
+ - lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt
99
111
  - lib/generators/fuik/provider/templates/loops/base.rb.tt
100
112
  - lib/generators/fuik/provider/templates/loops/contact_unsubscribed.rb.tt
101
113
  - lib/generators/fuik/provider/templates/loops/email_hard_bounced.rb.tt
@@ -110,6 +122,11 @@ files:
110
122
  - lib/generators/fuik/provider/templates/resend/base.rb.tt
111
123
  - lib/generators/fuik/provider/templates/resend/email_bounced.rb.tt
112
124
  - lib/generators/fuik/provider/templates/resend/email_complained.rb.tt
125
+ - lib/generators/fuik/provider/templates/smtp2go/base.rb.tt
126
+ - lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt
127
+ - lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt
128
+ - lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt
129
+ - lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt
113
130
  - lib/generators/fuik/provider/templates/stripe/base.rb.tt
114
131
  - lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt
115
132
  - lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt
@@ -137,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
137
154
  - !ruby/object:Gem::Version
138
155
  version: '0'
139
156
  requirements: []
140
- rubygems_version: 4.0.9
157
+ rubygems_version: 4.0.14
141
158
  specification_version: 4
142
159
  summary: A fish trap for webhooks
143
160
  test_files: []