-
Notifications
You must be signed in to change notification settings - Fork 120
Grida Forms - Respondent Email Notifications #522
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
Changes from all commits
4c57b44
fb08a31
122a0c0
8c5601e
35203b0
37e2852
4db136c
cc0637b
a4f9b67
4244771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| --- | ||
| title: Respondent email notifications | ||
| description: Learn how to send a custom confirmation email to respondents after a form submission in Grida Forms (CIAM verified email required). | ||
| --- | ||
|
|
||
| ### Respondent email notifications | ||
|
|
||
| Respondent email notifications let you send a **custom confirmation email** to the person who submitted your form. | ||
|
|
||
| This is useful for signup and registration forms where you want to: | ||
|
|
||
| - confirm the submission | ||
| - share next steps | ||
| - include a reference like a submission ID | ||
|
|
||
| ### Before you start (CIAM / verified email) | ||
|
|
||
| Grida sends respondent emails **only when CIAM is used** and the respondent has a **verified email**. | ||
|
|
||
| Practically, this means: | ||
|
|
||
| - your form should include a `challenge_email` field (CIAM email verification) | ||
| - the email is sent to the verified email associated with the submission (not to an arbitrary input field) | ||
|
|
||
| ### How to enable respondent email notifications | ||
|
|
||
| 1. Open your **Form** in the Grida editor. | ||
| 2. In the left sidebar, click **Connect**. | ||
| 3. Click **Channels**. | ||
| 4. Under **Email Notifications**, find **Respondent email notifications**. | ||
| 5. Toggle **Enable** on. | ||
| 6. Click **Save**. | ||
|
|
||
| ### How to customize the email | ||
|
|
||
| 1. Open **Connect → Channels → Email Notifications** (same as above). | ||
| 2. Configure the email fields: | ||
| - **Reply-To** (optional): where replies should go (e.g. `support@yourdomain.com`) | ||
| - **Subject**: the email subject template | ||
| - **From name** (optional): the sender display name (e.g. `Acme Support`) | ||
| - **Body (HTML)**: the email body template (HTML) | ||
| 3. Use the built-in preview to check your subject/body. | ||
| 4. Click **Save**. | ||
|
|
||
| ### What gets sent (high level) | ||
|
|
||
| - **Recipient**: the respondent’s **verified email** (CIAM) | ||
| - **From email**: a fixed no-reply address (display name can be customized with **From name**) | ||
| - **When it sends**: after a successful form submission | ||
| - if CIAM isn’t present or email isn’t verified, the email is skipped | ||
|
|
||
| ### Templating (Handlebars variables) | ||
|
|
||
| Subject and Body support template variables. | ||
|
|
||
| #### Available variables | ||
|
|
||
| - `{{form_title}}` | ||
| - `{{response.idx}}` (formatted submission index) | ||
| - `{{fields.<field_name>}}` (submitted fields by field name) | ||
|
|
||
| #### Examples | ||
|
|
||
| Subject: | ||
|
|
||
| ```txt | ||
| Thanks for registering for {{form_title}} | ||
| ``` | ||
|
|
||
| Body (HTML): | ||
|
|
||
| ```html | ||
| <h1>Thanks, {{fields.first_name}}!</h1> | ||
| <p>We received your submission for {{form_title}}.</p> | ||
| <p>Your registration number: {{response.idx}}</p> | ||
| ``` | ||
|
|
||
| ### Troubleshooting | ||
|
|
||
| If emails are not being sent: | ||
|
|
||
| - **CIAM not enabled**: ensure your form includes a `challenge_email` field | ||
| - **Email not verified**: respondent must complete verification; unverified emails are skipped | ||
| - **Missing body template**: sending is skipped if the body is empty | ||
| - **Delivery reliability**: sending is currently best-effort inline. Retries/queueing may be added later. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import assert from "assert"; | ||
| import { notFound } from "next/navigation"; | ||
| import validator from "validator"; | ||
| import { service_role } from "@/lib/supabase/server"; | ||
| import { resend } from "@/clients/resend"; | ||
| import { renderRespondentEmail } from "@/services/form/respondent-email"; | ||
|
|
||
| type Params = { id: string }; | ||
|
|
||
| /** | ||
| * Guard this public hook endpoint with a shared S2S key. | ||
| * | ||
| * This route uses `service_role` and can send emails, so it must not be | ||
| * callable by arbitrary third-parties. | ||
| */ | ||
| const GRIDA_S2S_PRIVATE_API_KEY = process.env.GRIDA_S2S_PRIVATE_API_KEY; | ||
|
|
||
| export async function POST( | ||
| req: NextRequest, | ||
| context: { | ||
| params: Promise<Params>; | ||
| } | ||
| ) { | ||
| const provided = | ||
| req.headers.get("x-grida-s2s-key") ?? req.headers.get("x-hook-secret"); | ||
| if (!GRIDA_S2S_PRIVATE_API_KEY) { | ||
| console.error( | ||
| "notification-respondent-email/err/misconfigured: GRIDA_S2S_PRIVATE_API_KEY missing" | ||
| ); | ||
| return NextResponse.json({ ok: false }, { status: 500 }); | ||
| } | ||
| if (!provided) { | ||
| return NextResponse.json({ ok: false }, { status: 401 }); | ||
| } | ||
| if (provided !== GRIDA_S2S_PRIVATE_API_KEY) { | ||
| return NextResponse.json({ ok: false }, { status: 403 }); | ||
| } | ||
|
|
||
| const { id: form_id } = await context.params; | ||
| const { response_id } = await req.json(); | ||
|
|
||
| assert(form_id, "form_id is required"); | ||
| assert(response_id, "response_id is required"); | ||
|
|
||
| const { data: form, error: form_err } = await service_role.forms | ||
| .from("form") | ||
| .select( | ||
| ` | ||
| id, | ||
| title, | ||
| notification_respondent_email, | ||
| fields:attribute(id, name, type) | ||
| ` | ||
| ) | ||
| .eq("id", form_id) | ||
| .single(); | ||
|
|
||
| if (form_err) | ||
| console.error("notification-respondent-email/err/form", form_err); | ||
| if (!form) return notFound(); | ||
|
Comment on lines
+40
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.tsRepository: gridaco/grida Length of output: 4778 🌐 Web query:
💡 Result:
For an API endpoint (Route Handler in // app/api/users/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) return Response.json({ error: "Not found" }, { status: 404 })
return Response.json(user)
}
If you’re using Pages Router API routes ( res.status(404).json({ error: "Not found" })Sources 🌐 Web query:
💡 Result: In Next.js 16 Route Handlers (
Minimal pattern (catch invalid JSON → 400)// app/api/foo/route.ts
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json(); // call ONCE
} catch (err) {
return Response.json(
{ error: "Invalid JSON" },
{ status: 400 }
);
}
return Response.json({ ok: true, body });
}Next.js explicitly documents reading the body this way in Route Handlers. (nextjs.org) Avoid “Body is unusable” (don’t read twice)If you do something like If you need the body twice, read it once and reuse the value, or use If you want to treat empty body as “no JSON” (optional)const text = await request.text();
if (!text) return Response.json({ ok: true, body: null });
let body;
try { body = JSON.parse(text); }
catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }Citations:
Return explicit JSON 400/404 instead of throwing.
✅ Suggested error handling pattern@@
-import assert from "assert";
-import { notFound } from "next/navigation";
@@
const { id: form_id } = await context.params;
- const { response_id } = await req.json();
-
- assert(form_id, "form_id is required");
- assert(response_id, "response_id is required");
+ let body: { response_id?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json(
+ { ok: false, error: "invalid_json" },
+ { status: 400 }
+ );
+ }
+ const { response_id } = body ?? {};
+ if (!form_id || !response_id) {
+ return NextResponse.json(
+ { ok: false, error: "missing_params" },
+ { status: 400 }
+ );
+ }
@@
- if (!form) return notFound();
+ if (!form)
+ return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });Also applies to: 62-65, 82-83 🤖 Prompt for AI Agents |
||
|
|
||
| const cfg = form.notification_respondent_email; | ||
|
|
||
| if (!cfg.enabled) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "disabled" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { data: response, error: response_err } = await service_role.forms | ||
| .from("response") | ||
| .select("id, form_id, raw, local_index, local_id, customer_id") | ||
| .eq("id", response_id) | ||
| .eq("form_id", form_id) | ||
| .single(); | ||
|
|
||
| if (response_err) | ||
| console.error("notification-respondent-email/err/response", response_err); | ||
| if (!response) return notFound(); | ||
|
|
||
| const customer_id = response.customer_id?.trim() || null; | ||
| if (!customer_id) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "missing_customer" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { data: customer, error: customer_err } = await service_role.workspace | ||
| .from("customer") | ||
| .select("uid, email, is_email_verified") | ||
| .eq("uid", customer_id) | ||
| .single(); | ||
|
|
||
| if (customer_err) | ||
| console.error("notification-respondent-email/err/customer", customer_err); | ||
| if (!customer) return notFound(); | ||
|
|
||
| const to = (customer.email ?? "").trim(); | ||
| if (!to || !customer.is_email_verified || !validator.isEmail(to)) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "unverified_email" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const raw = (response.raw ?? {}) as Record<string, unknown>; | ||
|
|
||
| const htmlSource = cfg.body_html_template?.trim(); | ||
| if (!htmlSource) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "missing_body_template" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { subject, html } = renderRespondentEmail({ | ||
| form_title: form.title, | ||
| raw, | ||
| response_local_index: Number(response.local_index ?? 0), | ||
| response_local_id: response.local_id ?? null, | ||
| subject_template: cfg.subject_template ?? null, | ||
| body_html_template: htmlSource, | ||
| }); | ||
|
|
||
| const replyTo = cfg.reply_to?.trim() || undefined; | ||
| const replyToSafe = | ||
| replyTo && validator.isEmail(replyTo) ? replyTo : undefined; | ||
|
|
||
| const fromName = cfg.from_name?.trim() || "Grida Forms"; | ||
|
|
||
| try { | ||
| await resend.emails.send({ | ||
| from: `${fromName} <no-reply@accounts.grida.co>`, | ||
| to: [to], | ||
| subject, | ||
| html, | ||
| replyTo: replyToSafe, | ||
| tags: [ | ||
| { name: "type", value: "notification_respondent_email" }, | ||
| { name: "form_id", value: form_id }, | ||
| ], | ||
| }); | ||
| } catch (e) { | ||
| console.error("notification-respondent-email/err/send", e); | ||
| return NextResponse.json({ ok: false }, { status: 500 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ ok: true }, { status: 200 }); | ||
|
Comment on lines
19
to
151
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check the directory structure around this endpoint
echo "=== Directory structure of public submit API ==="
find editor/app/\(api\)/\(public\)/v1/submit -type f -name "*.ts" | head -30
</parameter>Repository: gridaco/grida Length of output: 652 🏁 Script executed: #!/bin/bash
# Look for how this endpoint is called - client-side invocations
echo "=== Search for POST calls to notification-respondent-email ==="
rg -n "notification-respondent-email" -g '*.{ts,tsx,js,jsx}' -A 3 -B 3 --max-count=15
echo ""
echo "=== Search for where response_id is passed to the client ==="
rg -n "response_id" -g '*.{ts,tsx,js,jsx}' -B 2 -A 2 | head -50
</parameter>Repository: gridaco/grida Length of output: 11615 🏁 Script executed: #!/bin/bash
# Check for idempotency or rate limiting mechanisms
echo "=== Search for notification_sent_at, idempotency, or rate limit flags ==="
rg -n "(notification_sent|idempotenc|rate_limit|sent_at)" -g '*.{ts,tsx}' --max-count=30
echo ""
echo "=== Check the hooks.ts file to see how the endpoint is called ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts | head -80
</parameter>Repository: gridaco/grida Length of output: 2846 🏁 Script executed: #!/bin/bash
# Check the main submit route to see context of hook invocation
echo "=== Check how notification hook is invoked in main submit handler ==="
rg -B 5 -A 5 "notification-respondent-email" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -50
</parameter>Repository: gridaco/grida Length of output: 555 🏁 Script executed: #!/bin/bash
# Check if the fetch call has any auth headers or signing
echo "=== Full hooks.ts to check for auth/signing ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts
echo ""
echo "=== Check response table schema for notification tracking ==="
rg -n "response" -g '*.ts' "database.types" | grep -i "notification\|sent" | head -20
</parameter>Repository: gridaco/grida Length of output: 4686 🏁 Script executed: #!/bin/bash
# Check if response_id is truly public/exposed
echo "=== Search for where response is returned to client after submit ==="
rg -B 3 -A 3 "response_reference_obj.id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -40
</parameter>Repository: gridaco/grida Length of output: 1279 🏁 Script executed: #!/bin/bash
# Check the response table schema to confirm no notification tracking exists
echo "=== Search database schema files ==="
find editor -name "*.ts" -path "*database*" -o -name "database.types.ts" 2>/dev/null | head -10
echo ""
echo "=== Search for response table type definition ==="
rg -n "interface.*[Rr]esponse\|type.*[Rr]esponse" -g '*.ts' --max-count=5 | grep -v node_modules | head -20
</parameter>Repository: gridaco/grida Length of output: 320 🏁 Script executed: #!/bin/bash
# Check if there's any rate limiting middleware or request signing on the hook
echo "=== Check for middleware or auth on the hook route ==="
ls -la editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/
echo ""
echo "=== Look for any signing or secret verification in route ==="
cat editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -150
</parameter>Repository: gridaco/grida Length of output: 4242 🏁 Script executed: #!/bin/bash
# Verify if the endpoint is truly publicly accessible with no auth
echo "=== Check if there's a middleware.ts that might protect the route ==="
find editor/app/\(api\)/\(public\)/v1/submit -name "middleware.ts" 2>/dev/null
echo ""
echo "=== Check main middleware.ts for route protection ==="
rg -n "notification-respondent-email\|/v1/submit.*hooks" editor/middleware.ts 2>/dev/null || echo "Not in main middleware"
echo ""
echo "=== Look for any rate limiting library imports in route ==="
rg -n "rate|limit|throttle|ratelimit" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts
</parameter>Repository: gridaco/grida Length of output: 390 🏁 Script executed: #!/bin/bash
# Check if there's request signing or HMAC verification anywhere
echo "=== Search for HMAC, signature, or secret verification in the codebase ==="
rg -n "hmac|signature|crypto.*sign|verify.*sign" -g '*.ts' editor/app/\(api\)/\(public\)/v1/submit --max-count=10
</parameter>Repository: gridaco/grida Length of output: 254 🏁 Script executed: #!/bin/bash
# Check if the endpoint requires a secret/token header
echo "=== Check all imports and dependencies in the route ==="
head -10 editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts
echo ""
echo "=== Look for environment variables or secrets in the route file ==="
rg -n "process.env\|SECRET\|TOKEN\|AUTH" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.tsRepository: gridaco/grida Length of output: 549 🏁 Script executed: #!/bin/bash
# Verify the form_id parameter comes from URL (not from request body)
echo "=== Confirm form_id source in route ==="
rg -B 2 -A 2 "context.params\|form_id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -20Repository: gridaco/grida Length of output: 96 Public hook can be abused to resend notifications repeatedly. This endpoint is under a public submit path with no auth, signing, rate limiting, or idempotency tracking. The Add an idempotency key (one per response), track 🤖 Prompt for AI Agents
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.