Technical Documentation

Excel Care Group — Email Service

How website form submissions are delivered by email, why it is built this way, and how to operate it.

1. Overview & goals

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.

GoalHow it is met
Reliable inbox delivery (not spam)Resend + domain authentication (SPF/DKIM/DMARC)
No separate infrastructureRuns inside the existing Firebase project — one firebase deploy
SecureShared API key, input escaping, restricted CORS, secrets in Secret Manager
FlexibleAccepts arbitrary form fields — any form can reuse it

2. Why this design (the "why")

Why a backend at all?

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.

Why Firebase Functions (not a separate Cloud Run service)?

Single platform. The website is already hosted on Firebase Hosting. Putting the email logic in a Firebase Function means one project, one deploy pipeline, one place for secrets — no extra Docker image or Cloud Run service to maintain.
OptionTrade-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

Why Resend (not raw Gmail SMTP)?

Deliverability. Sending through Gmail SMTP from a web form frequently lands in spam because the message's domain authentication does not align. Resend is a dedicated transactional provider: once our domain is verified, every message is DKIM-signed for excelcaregroup.com.au, which mailbox providers trust.

3. Architecture (the "what")

React form  →  sendEmail.ts  →  POST /api/send-email  →  Firebase Function  →  Resend API  →  Staff inbox
LayerFile / ServiceResponsibility
Frontend helperclient/src/lib/sendEmail.tsPOSTs { subject, fields, replyTo } as JSON
Same-origin routefirebase.json rewriteMaps /api/send-email → the function (avoids CORS)
Backendfunctions/src/index.tsAuth, validation, builds email, calls Resend
Email providerResendDKIM-signs & delivers the message
Same-origin trick: because the function is exposed at /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.

4. Deliverability — staying out of spam

Mailbox providers decide "inbox vs spam" largely on three DNS records for the sending domain:

RecordWhat it provesSet up via
SPFResend's servers are authorised to send for our domainResend domain auth wizard
DKIMThe message was cryptographically signed by us and not alteredResend domain auth wizard (DNS records)
DMARCA published policy that ties SPF + DKIM togetherOne TXT record you add manually

Practical checklist to avoid spam

  1. Complete Resend Domain Verification for excelcaregroup.com.au and add the DNS records it gives you.
  2. Send from a real domain address (noreply@excelcaregroup.com.au) — never a free @gmail.com address.
  3. Add a DMARC TXT record: v=DMARC1; p=none; rua=mailto:dmarc@excelcaregroup.com.au (start with p=none, tighten later).
  4. Use a meaningful Subject and avoid spam-trigger words / ALL CAPS / excessive links.
  5. Set Reply-To to the form submitter so staff can reply directly.
  6. Verify with a tool like mail-tester.com (aim for 9–10/10).
Most important: if you only do one thing, complete Resend's domain verification. Without DKIM, messages will keep landing in spam no matter what else you change.

5. API reference & examples

POST /api/send-email

Request headers

HeaderRequiredValue
Content-TypeYesapplication/json
X-Api-KeyYesShared secret matching the function's FORM_API_KEY

Request body

{
  "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"
}
FieldTypeNotes
subjectstringOptional. Defaults to "New website enquiry".
fieldsobjectRequired. Any key/value pairs; rendered as a table in the email.
replyTostringOptional. Submitter's email; staff replies go straight to them.

Responses

StatusMeaning
200 { "success": true }Email accepted & sent
400Missing/invalid fields
403Missing or wrong X-Api-Key
405Method other than POST
502Resend delivery failed

Example — from the frontend helper

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
);

Example — raw cURL test

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"
      }'

6. Configuration & secrets

Secrets are stored in Google Secret Manager (never in code or Git).

NameTypeWhere it livesPurpose
RESEND_API_KEYSecretFirebase / Secret ManagerResend API key with send permission
FORM_API_KEYSecretFirebase / Secret ManagerShared key the browser sends in X-Api-Key
VITE_EMAIL_API_KEYSecretGitHub ActionsBuild-time copy of FORM_API_KEY baked into the frontend
SENDER_EMAILParamfirebase.json / envVerified "from" address
RECIPIENT_EMAILParamfirebase.json / envDestination inbox
ALLOWED_ORIGINSParamfirebase.json / envComma-separated allowed browser origins
Critical: 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.

7. One-time setup steps

1Create a Resend account & API key

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.

2Verify the domain (the anti-spam step)

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".

3Store the secrets in Firebase

cd functions
firebase functions:secrets:set RESEND_API_KEY
firebase functions:secrets:set FORM_API_KEY

4Add the GitHub Actions secret

In the GitHub repo → Settings → Secrets and variables → Actions, add VITE_EMAIL_API_KEY with the same value as FORM_API_KEY.

5Install function dependencies

cd functions
npm install

8. Deployment

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

9. Security notes

10. Troubleshooting

SymptomLikely cause & fix
403 ForbiddenVITE_EMAIL_API_KEY (build) ≠ FORM_API_KEY (function). Re-sync the values.
Emails land in spamDomain verification incomplete. Finish Resend DKIM setup & add DMARC.
502 delivery failedBad/disabled RESEND_API_KEY, or sender domain not verified. Check function logs.
CORS error in browserUse the same-origin /api/send-email path, or add the origin to ALLOWED_ORIGINS.
Nothing arrives, no errorCheck Resend → Logs/Emails and firebase functions:log.