-
Notifications
You must be signed in to change notification settings - Fork 199
feat(blog): notify google chat on newsletter form submit #414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nehagup
merged 15 commits into
keploy:main
from
dhananjay6561:feat/blog-form-gchat-notify
Aug 26, 2026
Merged
Changes from 2 commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
1d21b2a
feat(blog): notify google chat on newsletter form submit
dhananjay6561 c6733f6
fix(blog): address review on gchat notify endpoint
dhananjay6561 dbbe9dc
fix(blog): make the notify rate limit actually hold
dhananjay6561 c3e9524
refactor(blog): match landing repo's notify endpoint faithfully
dhananjay6561 4490b1c
fix(blog): align notify endpoint with repo conventions
dhananjay6561 70c7b29
fix(blog): drop honeypot submissions client-side too
dhananjay6561 040f089
test(blog): cover the gchat notify path + align with blog-mql
dhananjay6561 8d42e77
fix(blog): restore chat sanitizer + rate limit, guard double-submit, …
dhananjay6561 0d51dbe
docs(blog): document Chat PII retention for the notify endpoint
dhananjay6561 d9ea4a8
fix(blog): keep underscores in leads, surface non-OK Chat status
dhananjay6561 4a59efd
fix(blog): make the double-submit guard airtight with a ref
dhananjay6561 df949a1
docs(blog): fix the sanitizer rationale to match the strip set
dhananjay6561 253920b
fix(blog): surface silent notify failures in prod, tighten validation
dhananjay6561 4542438
Merge branch 'main' into feat/blog-form-gchat-notify
dhananjay6561 8f4ff8b
fix(blog): make maxDuration effective on pages route, sanitize name b…
dhananjay6561 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||
|
|
||
| // Server-side handler for blog newsletter / lead submissions. It forwards each | ||
| // lead to a Google Chat space via an incoming webhook (GOOGLE_CHAT_WEBHOOK_URL). | ||
| // Nothing is persisted here — the newsletter subscription (api-server) and the | ||
| // MQL lead (telemetry /blog-mql) are still handled by their own paths. This is | ||
| // a notification-only side channel, mirroring the landing repo's trial form. | ||
| // | ||
| // Security model: | ||
| // • GOOGLE_CHAT_WEBHOOK_URL is a server-only secret (NOT NEXT_PUBLIC_*), so it | ||
| // never reaches the browser bundle. This handler runs server-side only, so | ||
| // the webhook URL is never exposed to the client, git, or logs. | ||
| // • Create it in Google Chat: open the space → Apps & integrations → Webhooks | ||
| // → add one → copy the URL into the env var. Rotate by deleting/recreating. | ||
| // • Abuse: a hidden honeypot field + a best-effort per-IP rate limit keep | ||
| // casual flooding out. Stronger gating (reCAPTCHA Enterprise verification, | ||
| // matching the /blog-mql path) is tracked as a follow-up. | ||
| // If the env var is unset the submission still succeeds for the user, but the | ||
| // lead is NOT delivered. | ||
|
|
||
| const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; | ||
|
|
||
| // Best-effort in-memory per-IP rate limit. On serverless this is per-instance, | ||
| // not global, so it caps bursts against a hot instance rather than guaranteeing | ||
| // a hard ceiling — enough to blunt casual flooding of the chat space without | ||
| // pulling in an external store. | ||
| const RATE_LIMIT_MAX = 5; // requests | ||
| const RATE_LIMIT_WINDOW_MS = 60_000; // per minute, per IP | ||
| const rateHits = new Map<string, number[]>(); | ||
|
|
||
| function isRateLimited(ip: string): boolean { | ||
| const now = Date.now(); | ||
| const cutoff = now - RATE_LIMIT_WINDOW_MS; | ||
| const hits = (rateHits.get(ip) || []).filter((t) => t > cutoff); | ||
| hits.push(now); | ||
| rateHits.set(ip, hits); | ||
| return hits.length > RATE_LIMIT_MAX; | ||
| } | ||
|
|
||
| function clientIp(req: NextApiRequest): string { | ||
| const fwd = req.headers["x-forwarded-for"]; | ||
| const raw = Array.isArray(fwd) ? fwd[0] : fwd; | ||
| return (raw?.split(",")[0].trim() || req.socket.remoteAddress || "unknown"); | ||
| } | ||
|
|
||
| // Strip characters that carry meaning in Google Chat `text` messages so user | ||
| // input can't inject formatting (*bold* / _italic_), fake clickable links | ||
| // (<url|label>), or break the layout with newlines. Also caps length. | ||
| function sanitize(value: string, max = 200): string { | ||
| return value | ||
| .replace(/[<>|*_`\r\n]/g, " ") | ||
| .replace(/\s+/g, " ") | ||
| .trim() | ||
| .slice(0, max); | ||
| } | ||
|
|
||
| // Track the "webhook not configured" warning so it's logged once per instance | ||
| // instead of on every submit — the off state is expected until the env is set. | ||
| let warnedMissingWebhook = false; | ||
|
|
||
| export default async function handler( | ||
| req: NextApiRequest, | ||
| res: NextApiResponse, | ||
| ) { | ||
| if (req.method !== "POST") { | ||
| res.setHeader("Allow", "POST"); | ||
| return res.status(405).json({ ok: false, error: "method_not_allowed" }); | ||
| } | ||
|
|
||
| const data = (req.body && typeof req.body === "object" ? req.body : {}) as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
|
|
||
| // Honeypot — the form renders a hidden `company_website` input that humans | ||
| // never see; a filled value means a bot. Silently accept and drop. | ||
| if (data.company_website) { | ||
|
dhananjay6561 marked this conversation as resolved.
dhananjay6561 marked this conversation as resolved.
|
||
| return res.status(200).json({ ok: true, delivered: false }); | ||
| } | ||
|
|
||
| if (isRateLimited(clientIp(req))) { | ||
| return res.status(429).json({ ok: false, error: "rate_limited" }); | ||
| } | ||
|
|
||
| const name = String(data.fullName ?? "").trim(); | ||
| const email = String(data.email ?? "").trim(); | ||
|
dhananjay6561 marked this conversation as resolved.
Outdated
|
||
| // Re-validate server-side: a request could hit this endpoint directly and | ||
| // bypass the client-side checks. | ||
| if (!name || !EMAIL_RE.test(email)) { | ||
|
dhananjay6561 marked this conversation as resolved.
dhananjay6561 marked this conversation as resolved.
|
||
| return res.status(400).json({ ok: false, error: "validation" }); | ||
| } | ||
|
|
||
| const lead = { | ||
|
dhananjay6561 marked this conversation as resolved.
|
||
| name: sanitize(name, 120), | ||
| email: sanitize(email, 254), | ||
| company: sanitize(String(data.companyName ?? ""), 160), | ||
| page: sanitize(String(data.page ?? ""), 500), | ||
| submittedAt: new Date().toISOString(), | ||
| }; | ||
|
|
||
| const webhook = process.env.GOOGLE_CHAT_WEBHOOK_URL; | ||
| if (!webhook) { | ||
| // No PII in logs. Set GOOGLE_CHAT_WEBHOOK_URL to deliver leads to the space. | ||
| // Expected steady state until the env is set, so warn once, not every submit. | ||
| if (!warnedMissingWebhook) { | ||
| warnedMissingWebhook = true; | ||
| console.warn( | ||
| "[blog-lead] GOOGLE_CHAT_WEBHOOK_URL is not configured — leads accepted but NOT delivered. Set the env var to enable delivery.", | ||
| ); | ||
| } | ||
| return res.status(200).json({ ok: true, delivered: false }); | ||
| } | ||
|
|
||
| try { | ||
| // page is rendered as plain (sanitized) text, not a <url|label> link, so a | ||
| // direct POST can't inject a misleading clickable URL. | ||
| const text = | ||
| `*📨 New Keploy blog subscriber*\n` + | ||
| `*Name:* ${lead.name}\n` + | ||
| `*Email:* ${lead.email}\n` + | ||
| `*Company:* ${lead.company || "—"}\n` + | ||
| `*Page:* ${lead.page || "—"}\n` + | ||
| `*Submitted:* ${lead.submittedAt}`; | ||
|
|
||
| const chatRes = await fetch(webhook, { | ||
|
dhananjay6561 marked this conversation as resolved.
|
||
| method: "POST", | ||
| headers: { "Content-Type": "application/json; charset=UTF-8" }, | ||
| body: JSON.stringify({ text }), | ||
| }); | ||
| return res.status(200).json({ ok: true, delivered: chatRes.ok }); | ||
| } catch (err) { | ||
| console.error( | ||
| "[blog-lead] delivery to Google Chat failed — verify GOOGLE_CHAT_WEBHOOK_URL is a valid incoming-webhook URL. Lead was NOT delivered.", | ||
| err, | ||
| ); | ||
| // Never fail the user — the newsletter subscription path is unaffected. | ||
| return res.status(200).json({ ok: true, delivered: false }); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.