fuik 0.10.1 → 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.
- checksums.yaml +4 -4
- data/README.md +25 -1
- data/app/assets/images/fuik/icons/46elks.jpg +0 -0
- data/app/assets/images/fuik/icons/lettermint.jpg +0 -0
- data/app/assets/images/fuik/icons/smtp2go.jpg +0 -0
- data/app/assets/javascript/attractivejs-0.12.0.min.js +1 -0
- data/app/assets/stylesheets/fuik/application.css +59 -0
- data/app/controllers/fuik/events_controller.rb +1 -1
- data/app/controllers/fuik/webhooks_controller.rb +20 -3
- data/app/helpers/fuik/highlight_helper.rb +1 -1
- data/app/jobs/fuik/webhook_processing_job.rb +7 -0
- data/app/models/fuik/webhook_event/filterable.rb +26 -0
- data/app/models/fuik/webhook_event.rb +2 -0
- data/app/views/fuik/events/_filters.html.erb +29 -0
- data/app/views/fuik/events/index.html.erb +2 -0
- data/app/views/layouts/fuik/application.html.erb +1 -1
- data/config/routes.rb +1 -1
- data/lib/fuik/engine.rb +3 -0
- data/lib/fuik/routing/provider_constraint.rb +34 -0
- data/lib/fuik/version.rb +1 -1
- data/lib/generators/fuik/provider/templates/github/installation_created.rb.tt +2 -2
- data/lib/generators/fuik/provider/templates/github/push.rb.tt +2 -4
- data/lib/generators/fuik/provider/templates/github/star_created.rb.tt +2 -2
- data/lib/generators/fuik/provider/templates/lettermint/base.rb.tt +32 -0
- data/lib/generators/fuik/provider/templates/lettermint/message_failed.rb.tt +16 -0
- data/lib/generators/fuik/provider/templates/lettermint/message_hard_bounced.rb.tt +16 -0
- data/lib/generators/fuik/provider/templates/lettermint/message_soft_bounced.rb.tt +16 -0
- data/lib/generators/fuik/provider/templates/lettermint/message_suppressed.rb.tt +15 -0
- data/lib/generators/fuik/provider/templates/loops/base.rb.tt +17 -0
- data/lib/generators/fuik/provider/templates/loops/contact_unsubscribed.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/loops/email_hard_bounced.rb.tt +14 -0
- data/lib/generators/fuik/provider/templates/mailgun/base.rb.tt +18 -0
- data/lib/generators/fuik/provider/templates/mailgun/bounced.rb.tt +14 -0
- data/lib/generators/fuik/provider/templates/mailgun/complained.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt +2 -1
- data/lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt +3 -0
- data/lib/generators/fuik/provider/templates/postmark/base.rb.tt +4 -0
- data/lib/generators/fuik/provider/templates/postmark/bounce.rb.tt +17 -0
- data/lib/generators/fuik/provider/templates/resend/base.rb.tt +11 -0
- data/lib/generators/fuik/provider/templates/resend/email_bounced.rb.tt +14 -0
- data/lib/generators/fuik/provider/templates/resend/email_complained.rb.tt +12 -0
- data/lib/generators/fuik/provider/templates/smtp2go/base.rb.tt +4 -0
- data/lib/generators/fuik/provider/templates/smtp2go/bounce.rb.tt +16 -0
- data/lib/generators/fuik/provider/templates/smtp2go/reject.rb.tt +14 -0
- data/lib/generators/fuik/provider/templates/smtp2go/spam.rb.tt +13 -0
- data/lib/generators/fuik/provider/templates/smtp2go/unsubscribe.rb.tt +13 -0
- data/lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt +5 -5
- data/lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt +2 -1
- data/lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt +5 -4
- data/lib/generators/fuik/provider/templates/stripe/payment_intent_succeeded.rb.tt +5 -4
- data/lib/generators/fuik/provider/templates/userlist/base.rb.tt +18 -0
- data/lib/generators/fuik/provider/templates/userlist/user_unsubscribed.rb.tt +14 -0
- metadata +33 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a653582dac2c822bb4496a89d0dbd12cd9b96a082bdeed11c209793df27de21
|
|
4
|
+
data.tar.gz: 44070a1620d3d867cc5a400e7f61bbd58bebec6e713407d6da6a65d8d2d2037b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -124,6 +130,24 @@ end
|
|
|
124
130
|
If `Provider::Base.verify!` exists, Fuik calls it automatically. Invalid signatures return 401 without storing the webhook.
|
|
125
131
|
|
|
126
132
|
|
|
133
|
+
### Provider allowlist
|
|
134
|
+
|
|
135
|
+
By default:
|
|
136
|
+
- **Development/test**: all providers are allowed
|
|
137
|
+
- **Production/staging**: only providers in `app/webhooks/` are allowed
|
|
138
|
+
|
|
139
|
+
Configure with `Fuik::Engine.config.providers_allowed`:
|
|
140
|
+
```ruby
|
|
141
|
+
# Allow all (including production)
|
|
142
|
+
Fuik::Engine.config.providers_allowed = :all
|
|
143
|
+
|
|
144
|
+
# Explicit allowlist (overrides directory scan)
|
|
145
|
+
Fuik::Engine.config.providers_allowed = %w[stripe github shopify]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Unknown providers return `404 Not Found`.
|
|
149
|
+
|
|
150
|
+
|
|
127
151
|
### Pre-packaged providers
|
|
128
152
|
|
|
129
153
|
Fuik includes ready-to-use [templates for common providers](https://github.com/Rails-Designer/fuik/tree/main/lib/generators/fuik/provider/templates).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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;
|
|
@@ -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:
|
|
16
|
+
body: json_body,
|
|
17
17
|
headers: headers
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
-
|
|
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(
|
|
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
|
|
@@ -31,7 +31,7 @@ module Fuik
|
|
|
31
31
|
|
|
32
32
|
object.each_with_index.map do |(key, value), index|
|
|
33
33
|
key_path = current_path + [key]
|
|
34
|
-
path_string = key_path.map { "[\"#{it}\"]" }.join
|
|
34
|
+
path_string = key_path.map { it.is_a?(String) ? "[\"#{it}\"]" : "[#{it}]" }.join
|
|
35
35
|
|
|
36
36
|
comma = (index == object.size - 1) ? "" : '<span class="json-punctuation">,</span>'
|
|
37
37
|
|
|
@@ -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
|
|
@@ -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 %>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<%= yield :head %>
|
|
9
9
|
|
|
10
10
|
<%= stylesheet_link_tag "fuik/application", media: "all" %>
|
|
11
|
-
<script
|
|
11
|
+
<script src="<%= asset_path("attractivejs-0.12.0.min.js") %>"></script>
|
|
12
12
|
</head>
|
|
13
13
|
<body>
|
|
14
14
|
<main>
|
data/config/routes.rb
CHANGED
data/lib/fuik/engine.rb
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fuik/routing/provider_constraint"
|
|
4
|
+
|
|
3
5
|
module Fuik
|
|
4
6
|
class Engine < ::Rails::Engine
|
|
5
7
|
isolate_namespace Fuik
|
|
6
8
|
|
|
7
9
|
config.webhooks_controller_parent = "ActionController::Base"
|
|
8
10
|
config.events_controller_parent = "ActionController::Base"
|
|
11
|
+
config.providers_allowed = Rails.env.local?
|
|
9
12
|
|
|
10
13
|
config.to_prepare do
|
|
11
14
|
ActiveSupport.on_load(:action_view) do
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fuik
|
|
4
|
+
module Routing
|
|
5
|
+
class ProviderConstraint
|
|
6
|
+
def matches?(request)
|
|
7
|
+
return true if allow_all?
|
|
8
|
+
return explicit_allowlist.include?(request.params[:provider]) if explicit_allowlist?
|
|
9
|
+
|
|
10
|
+
return Rails.env.local? if providers_allowed.nil?
|
|
11
|
+
return Rails.env.local? if providers_allowed == true
|
|
12
|
+
|
|
13
|
+
scanned_allowlist.include?(request.params[:provider])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def allow_all? = Fuik::Engine.config.providers_allowed.in?([:all, "all"])
|
|
19
|
+
|
|
20
|
+
def explicit_allowlist? = Fuik::Engine.config.providers_allowed.is_a?(Array)
|
|
21
|
+
|
|
22
|
+
def explicit_allowlist = Fuik::Engine.config.providers_allowed.to_set
|
|
23
|
+
|
|
24
|
+
def providers_allowed = Fuik::Engine.config.providers_allowed
|
|
25
|
+
|
|
26
|
+
def scanned_allowlist
|
|
27
|
+
@scanned_allowlist ||= Dir["#{Rails.root}/app/webhooks/*"]
|
|
28
|
+
.select { File.directory?(it) }
|
|
29
|
+
.map { File.basename(it) }
|
|
30
|
+
.to_set
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/fuik/version.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module <%= provider_module_name %>
|
|
2
2
|
class InstallationCreated < Base
|
|
3
3
|
def process!
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# payload["installation"]["id"] # GitHub app installation ID
|
|
5
|
+
# payload["installation"]["account"] # user/org that installed
|
|
6
6
|
|
|
7
7
|
# TODO: Add business logic
|
|
8
8
|
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
module <%= provider_module_name %>
|
|
2
2
|
class Push < Base
|
|
3
3
|
def process!
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
repository = payload.dig("repository", "full_name")
|
|
7
|
-
ref = payload["ref"]
|
|
4
|
+
# payload["ref"] # git ref (e.g., "refs/heads/main")
|
|
5
|
+
# payload["repository"]["full_name"] # "owner/repo"
|
|
8
6
|
|
|
9
7
|
# TODO: Add business logic
|
|
10
8
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module <%= provider_module_name %>
|
|
2
2
|
class StarCreated < Base
|
|
3
3
|
def process!
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# payload["repository"]["full_name"] # "owner/repo"
|
|
5
|
+
# payload["sender"]["login"] # user who starred
|
|
6
6
|
|
|
7
7
|
# TODO: Add business logic
|
|
8
8
|
|
|
@@ -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,17 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Base < Fuik::Event
|
|
3
|
+
def self.verify!(request)
|
|
4
|
+
secret = Rails.application.credentials.dig(:loops, :signing_secret)
|
|
5
|
+
secret_bytes = Base64.strict_decode64(secret.split("_")[1])
|
|
6
|
+
|
|
7
|
+
webhook_id = request.headers["webhook-id"]
|
|
8
|
+
timestamp = request.headers["webhook-timestamp"]
|
|
9
|
+
signature = request.headers["webhook-signature"]
|
|
10
|
+
|
|
11
|
+
signed_content = "#{webhook_id}.#{timestamp}.#{request.raw_post}"
|
|
12
|
+
expected_signature = OpenSSL::HMAC.digest("SHA256", secret_bytes, signed_content)
|
|
13
|
+
|
|
14
|
+
raise Fuik::InvalidSignature unless signature.split(" ").any? { |sig| sig.include?(",#{expected_signature}") }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class ContactUnsubscribed < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["contactIdentity"]["email"] # user email
|
|
5
|
+
# payload["contactIdentity"]["id"] # Loops contact ID
|
|
6
|
+
|
|
7
|
+
# TODO: Add business logic
|
|
8
|
+
|
|
9
|
+
@webhook_event.processed!
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class EmailHardBounced < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["contact"]["email"] # recipient email
|
|
5
|
+
# payload["email"]["id"] # Loops email ID
|
|
6
|
+
# payload["email"]["emailMessageId"] # Postmark message ID
|
|
7
|
+
# payload["email"]["subject"] # email subject
|
|
8
|
+
|
|
9
|
+
# TODO: Add business logic
|
|
10
|
+
|
|
11
|
+
@webhook_event.processed!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Base < Fuik::Event
|
|
3
|
+
def self.verify!(request)
|
|
4
|
+
secret = Rails.application.credentials.dig(:mailgun, :signing_key)
|
|
5
|
+
signature_base64 = request.headers["Signature"]
|
|
6
|
+
timestamp = request.headers["Timestamp"]
|
|
7
|
+
token = request.headers["Token"]
|
|
8
|
+
|
|
9
|
+
signature = Base64.strict_decode64(signature_base64)
|
|
10
|
+
data = "#{timestamp}#{token}"
|
|
11
|
+
|
|
12
|
+
raise Fuik::InvalidSignature unless ActiveSupport::SecurityUtils.secure_compare(
|
|
13
|
+
OpenSSL::HMAC.digest("SHA256", secret, data),
|
|
14
|
+
signature
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Bounced < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["recipient"] # email address that bounced
|
|
5
|
+
# payload["code"] # bounce code (e.g., "MAILBOX_FULL")
|
|
6
|
+
# payload["error"] # SMTP error response
|
|
7
|
+
# payload["reason"] # human-readable reason
|
|
8
|
+
|
|
9
|
+
# TODO: Add business logic
|
|
10
|
+
|
|
11
|
+
@webhook_event.processed!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Complained < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["recipient"] # email address that marked as spam
|
|
5
|
+
# payload["timestamp"] # when the complaint occurred
|
|
6
|
+
|
|
7
|
+
# TODO: Add business logic
|
|
8
|
+
|
|
9
|
+
@webhook_event.processed!
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Bounce < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["Email"] # email address that bounced
|
|
5
|
+
# payload["Type"] # bounce type (e.g., "HardBounce", "SoftBounce")
|
|
6
|
+
# payload["BouncedAt"] # ISO 8601 timestamp
|
|
7
|
+
# payload["Inactive"] # boolean - address is deactivated
|
|
8
|
+
# payload["CanActivate"] # boolean - can be reactivated
|
|
9
|
+
# payload["MessageID"] # Postmark message ID
|
|
10
|
+
# payload["Description"] # human-readable description
|
|
11
|
+
|
|
12
|
+
# TODO: Add business logic
|
|
13
|
+
|
|
14
|
+
@webhook_event.processed!
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Base < Fuik::Event
|
|
3
|
+
def self.verify!(request)
|
|
4
|
+
secret = Rails.application.credentials.dig(:resend, :signing_secret)
|
|
5
|
+
signature = request.headers["Resend-Signature"]
|
|
6
|
+
expected_signature = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
|
|
7
|
+
|
|
8
|
+
raise Fuik::InvalidSignature unless Rack::Utils.secure_compare(signature, expected_signature)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class EmailBounced < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["data"]["emails"][0]["to"] # recipient email
|
|
5
|
+
# payload["data"]["bounce"]["classification"] # bounce type
|
|
6
|
+
# payload["data"]["bounce"]["subtype"] # bounce subtype
|
|
7
|
+
# payload["ts"] # timestamp
|
|
8
|
+
|
|
9
|
+
# TODO: Add business logic
|
|
10
|
+
|
|
11
|
+
@webhook_event.processed!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
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
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Stripe
|
|
2
2
|
class CheckoutSessionCompleted < Base
|
|
3
3
|
def process!
|
|
4
|
+
# payload["data"]["object"]["id"] # session ID
|
|
5
|
+
# payload["data"]["object"]["customer"] # customer ID
|
|
6
|
+
# payload["data"]["object"]["client_reference_id"] # your internal reference
|
|
7
|
+
# payload["data"]["object"]["amount_total"] # amount in cents
|
|
8
|
+
|
|
4
9
|
# TODO: Add business logic
|
|
5
|
-
# session = Stripe::Checkout::Session.retrieve(session_id) # this assumes the Stripe gem is available
|
|
6
10
|
|
|
7
11
|
@webhook_event.processed!
|
|
8
12
|
end
|
|
9
|
-
|
|
10
|
-
private
|
|
11
|
-
|
|
12
|
-
def session_id = payload.dig("data", "object", "id")
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module Stripe
|
|
2
2
|
class CustomerSubscriptionDeleted < Base
|
|
3
3
|
def process!
|
|
4
|
-
|
|
4
|
+
# payload["data"]["object"]["id"] # subscription ID
|
|
5
|
+
# payload["data"]["object"]["customer"] # customer ID
|
|
5
6
|
|
|
6
7
|
# TODO: Add business logic
|
|
7
8
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
module Stripe
|
|
2
2
|
class CustomerSubscriptionUpdated < Base
|
|
3
3
|
def process!
|
|
4
|
+
# payload["data"]["object"]["id"] # subscription ID
|
|
5
|
+
# payload["data"]["object"]["customer"] # customer ID
|
|
6
|
+
# payload["data"]["object"]["status"] # e.g., "active", "past_due"
|
|
7
|
+
# payload["data"]["object"]["price"] # price object
|
|
8
|
+
|
|
4
9
|
# TODO: Add business logic
|
|
5
10
|
|
|
6
11
|
@webhook_event.processed!
|
|
7
12
|
end
|
|
8
|
-
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def subscription_id = payload.dig("data", "object", "id")
|
|
12
13
|
end
|
|
13
14
|
end
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
module Stripe
|
|
2
2
|
class PaymentIntentSucceeded < Base
|
|
3
3
|
def process!
|
|
4
|
+
# payload["data"]["object"]["id"] # payment intent ID
|
|
5
|
+
# payload["data"]["object"]["amount"] # amount in cents
|
|
6
|
+
# payload["data"]["object"]["customer"] # customer ID
|
|
7
|
+
# payload["data"]["object"]["currency"] # e.g., "usd"
|
|
8
|
+
|
|
4
9
|
# TODO: Add business logic
|
|
5
10
|
|
|
6
11
|
@webhook_event.processed!
|
|
7
12
|
end
|
|
8
|
-
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def payment_intent_id = payload["data"]["object"]["id"]
|
|
12
13
|
end
|
|
13
14
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class Base < Fuik::Event
|
|
3
|
+
def self.verify!(request)
|
|
4
|
+
secret = Rails.application.credentials.dig(:userlist, :webhook_secret)
|
|
5
|
+
header = request.headers["Userlist-Signature"]
|
|
6
|
+
body = request.raw_post
|
|
7
|
+
|
|
8
|
+
parts = header.split(",")
|
|
9
|
+
timestamp = parts[0].split("=")[1]
|
|
10
|
+
signature = parts[1].split("=")[1]
|
|
11
|
+
|
|
12
|
+
signed_payload = "#{timestamp}.#{body}"
|
|
13
|
+
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
|
|
14
|
+
|
|
15
|
+
raise Fuik::InvalidSignature unless Rack::Utils.secure_compare(signature, expected_signature)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module <%= provider_module_name %>
|
|
2
|
+
class UserUnsubscribed < Base
|
|
3
|
+
def process!
|
|
4
|
+
# payload["user"]["id"] # Userlist user ID
|
|
5
|
+
# payload["user"]["identifier"] # your internal user ID
|
|
6
|
+
# payload["user"]["email"] # user email
|
|
7
|
+
# payload["user"]["properties"] # custom user properties
|
|
8
|
+
|
|
9
|
+
# TODO: Add business logic
|
|
10
|
+
|
|
11
|
+
@webhook_event.processed!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
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.
|
|
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
|
|
@@ -85,6 +92,7 @@ files:
|
|
|
85
92
|
- lib/fuik.rb
|
|
86
93
|
- lib/fuik/dot_access.rb
|
|
87
94
|
- lib/fuik/engine.rb
|
|
95
|
+
- lib/fuik/routing/provider_constraint.rb
|
|
88
96
|
- lib/fuik/version.rb
|
|
89
97
|
- lib/generators/fuik/install/install_generator.rb
|
|
90
98
|
- lib/generators/fuik/provider/provider_generator.rb
|
|
@@ -95,14 +103,37 @@ files:
|
|
|
95
103
|
- lib/generators/fuik/provider/templates/github/installation_created.rb.tt
|
|
96
104
|
- lib/generators/fuik/provider/templates/github/push.rb.tt
|
|
97
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
|
|
111
|
+
- lib/generators/fuik/provider/templates/loops/base.rb.tt
|
|
112
|
+
- lib/generators/fuik/provider/templates/loops/contact_unsubscribed.rb.tt
|
|
113
|
+
- lib/generators/fuik/provider/templates/loops/email_hard_bounced.rb.tt
|
|
114
|
+
- lib/generators/fuik/provider/templates/mailgun/base.rb.tt
|
|
115
|
+
- lib/generators/fuik/provider/templates/mailgun/bounced.rb.tt
|
|
116
|
+
- lib/generators/fuik/provider/templates/mailgun/complained.rb.tt
|
|
98
117
|
- lib/generators/fuik/provider/templates/mailpace/base.rb.tt
|
|
99
118
|
- lib/generators/fuik/provider/templates/mailpace/email_bounced.rb.tt
|
|
100
119
|
- lib/generators/fuik/provider/templates/mailpace/email_spam.rb.tt
|
|
120
|
+
- lib/generators/fuik/provider/templates/postmark/base.rb.tt
|
|
121
|
+
- lib/generators/fuik/provider/templates/postmark/bounce.rb.tt
|
|
122
|
+
- lib/generators/fuik/provider/templates/resend/base.rb.tt
|
|
123
|
+
- lib/generators/fuik/provider/templates/resend/email_bounced.rb.tt
|
|
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
|
|
101
130
|
- lib/generators/fuik/provider/templates/stripe/base.rb.tt
|
|
102
131
|
- lib/generators/fuik/provider/templates/stripe/checkout_session_completed.rb.tt
|
|
103
132
|
- lib/generators/fuik/provider/templates/stripe/customer_subscription_deleted.rb.tt
|
|
104
133
|
- lib/generators/fuik/provider/templates/stripe/customer_subscription_updated.rb.tt
|
|
105
134
|
- lib/generators/fuik/provider/templates/stripe/payment_intent_succeeded.rb.tt
|
|
135
|
+
- lib/generators/fuik/provider/templates/userlist/base.rb.tt
|
|
136
|
+
- lib/generators/fuik/provider/templates/userlist/user_unsubscribed.rb.tt
|
|
106
137
|
homepage: https://railsdesigner.com/fuik/
|
|
107
138
|
licenses:
|
|
108
139
|
- MIT
|
|
@@ -123,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
123
154
|
- !ruby/object:Gem::Version
|
|
124
155
|
version: '0'
|
|
125
156
|
requirements: []
|
|
126
|
-
rubygems_version: 4.0.
|
|
157
|
+
rubygems_version: 4.0.14
|
|
127
158
|
specification_version: 4
|
|
128
159
|
summary: A fish trap for webhooks
|
|
129
160
|
test_files: []
|