How website form submissions are delivered by email, why it is built this way, and how to operate it.
The email service lets any form on the Excel Care Group website (Contact, Referral,
Feedback, Careers) send its submitted data to a staff inbox
(samson.sam@excelcaregroup.com.au) as a formatted email.
It is implemented as a single Firebase Cloud Function that forwards mail through Resend.
| Goal | How it is met |
|---|---|
| Reliable inbox delivery (not spam) | Resend + domain authentication (SPF/DKIM/DMARC) |
| No separate infrastructure | Runs inside the existing Firebase project — one firebase deploy |
| Secure | Shared API key, input escaping, restricted CORS, secrets in Secret Manager |
| Flexible | Accepts arbitrary form fields — any form can reuse it |
A browser cannot safely send email on its own. Email requires an authenticated SMTP/API connection with a secret credential. If that credential lived in the frontend JavaScript, anyone could read it and send mail as us. A small backend keeps the secret server-side.
| Option | Trade-off |
|---|---|
| Firebase Function (chosen) | Same project as hosting, zero extra infra, generous free tier |
| Cloud Run (Python/FastAPI) | Works, but a separate service, Docker build, and deploy to manage |
| 3rd-party form widget (Formspree etc.) | Fast, but recurring cost, data leaves our control, less branding control |
excelcaregroup.com.au, which mailbox providers trust.
React form →
sendEmail.ts →
POST /api/send-email →
Firebase Function →
Resend API →
Staff inbox
| Layer | File / Service | Responsibility |
|---|---|---|
| Frontend helper | client/src/lib/sendEmail.ts | POSTs { subject, fields, replyTo } as JSON |
| Same-origin route | firebase.json rewrite | Maps /api/send-email → the function (avoids CORS) |
| Backend | functions/src/index.ts | Auth, validation, builds email, calls Resend |
| Email provider | Resend | DKIM-signs & delivers the message |
/api/send-email
on the same domain as the site (via a Hosting rewrite), the browser request is not
cross-origin — so there are no CORS preflight headaches in production.
Mailbox providers decide "inbox vs spam" largely on three DNS records for the sending domain:
| Record | What it proves | Set up via |
|---|---|---|
| SPF | Resend's servers are authorised to send for our domain | Resend domain auth wizard |
| DKIM | The message was cryptographically signed by us and not altered | Resend domain auth wizard (DNS records) |
| DMARC | A published policy that ties SPF + DKIM together | One TXT record you add manually |
excelcaregroup.com.au and add the DNS records it gives you.noreply@excelcaregroup.com.au) — never a free @gmail.com address.v=DMARC1; p=none; rua=mailto:dmarc@excelcaregroup.com.au (start with p=none, tighten later).Subject and avoid spam-trigger words / ALL CAPS / excessive links.Reply-To to the form submitter so staff can reply directly.POST /api/send-email
| Header | Required | Value |
|---|---|---|
Content-Type | Yes | application/json |
X-Api-Key | Yes | Shared secret matching the function's FORM_API_KEY |
{
"subject": "New Contact Form Enquiry",
"fields": {
"Name": "Jane Citizen",
"Email": "jane@example.com",
"Phone": "0400 000 000",
"Message": "I'd like to know more about your day programs."
},
"replyTo": "jane@example.com"
}
| Field | Type | Notes |
|---|---|---|
subject | string | Optional. Defaults to "New website enquiry". |
fields | object | Required. Any key/value pairs; rendered as a table in the email. |
replyTo | string | Optional. Submitter's email; staff replies go straight to them. |
| Status | Meaning |
|---|---|
200 { "success": true } | Email accepted & sent |
400 | Missing/invalid fields |
403 | Missing or wrong X-Api-Key |
405 | Method other than POST |
502 | Resend delivery failed |
import { sendEmail } from "@/lib/sendEmail";
await sendEmail(
"New Contact Form Enquiry",
{
Name: form.name,
Email: form.email,
Phone: form.phone,
Message: form.message,
},
form.email, // replyTo
);
curl -X POST https://webapp-dev-496513.web.app/api/send-email \
-H "Content-Type: application/json" \
-H "X-Api-Key: your-shared-secret" \
-d '{
"subject": "Test enquiry",
"fields": { "Name": "Test User", "Message": "Hello" },
"replyTo": "test@example.com"
}'
Secrets are stored in Google Secret Manager (never in code or Git).
| Name | Type | Where it lives | Purpose |
|---|---|---|---|
RESEND_API_KEY | Secret | Firebase / Secret Manager | Resend API key with send permission |
FORM_API_KEY | Secret | Firebase / Secret Manager | Shared key the browser sends in X-Api-Key |
VITE_EMAIL_API_KEY | Secret | GitHub Actions | Build-time copy of FORM_API_KEY baked into the frontend |
SENDER_EMAIL | Param | firebase.json / env | Verified "from" address |
RECIPIENT_EMAIL | Param | firebase.json / env | Destination inbox |
ALLOWED_ORIGINS | Param | firebase.json / env | Comma-separated allowed browser origins |
FORM_API_KEY and VITE_EMAIL_API_KEY must hold the
exact same value. The browser sends VITE_EMAIL_API_KEY; the function checks it
against FORM_API_KEY. If they differ, every request returns 403.
Sign up at resend.com, then create an API key with Send permission under API Keys. Copy it once — Resend will not show it again.
In Resend: Domains → Add Domain. Enter excelcaregroup.com.au, then add the DNS records it generates (DKIM, SPF, and a return-path/MX record) to your DNS. Wait for "Verified".
cd functions
firebase functions:secrets:set RESEND_API_KEY
firebase functions:secrets:set FORM_API_KEY
In the GitHub repo → Settings → Secrets and variables → Actions, add VITE_EMAIL_API_KEY with the same value as FORM_API_KEY.
cd functions
npm install
Deployment is driven by pushing a Git tag (same mechanism as the website):
git tag 20260601_001_dev_deploy
git push origin 20260601_001_dev_deploy
The GitHub Actions workflow builds the frontend, builds the function, and runs
firebase deploy --only hosting,functions. To deploy only the function locally:
firebase deploy --only functions
X-Api-Key are rejected with 403..env and lib/ are git-ignored.| Symptom | Likely cause & fix |
|---|---|
| 403 Forbidden | VITE_EMAIL_API_KEY (build) ≠ FORM_API_KEY (function). Re-sync the values. |
| Emails land in spam | Domain verification incomplete. Finish Resend DKIM setup & add DMARC. |
| 502 delivery failed | Bad/disabled RESEND_API_KEY, or sender domain not verified. Check function logs. |
| CORS error in browser | Use the same-origin /api/send-email path, or add the origin to ALLOWED_ORIGINS. |
| Nothing arrives, no error | Check Resend → Logs/Emails and firebase functions:log. |