A small Azure Static Web Apps site for collecting Civil Air Patrol membership inquiries and routing them to the appropriate squadron recruiting address.
The site presents a public interest form, validates the submission, verifies Cloudflare Turnstile, and sends the inquiry through Microsoft Graph from an Azure Functions API.
- Public membership inquiry form for prospective CAP members
- Squadron selector with recruiting email routing
- Membership-type-specific parent or guardian fields for cadet inquiries
- Client-side and server-side validation for required fields, email addresses, and phone numbers
- Cloudflare Turnstile bot protection
- Microsoft Graph
sendMaildelivery - Optional email copy and test override routing through environment settings
- Azure Static Web Apps deployment workflow
.
├── api/ Azure Functions API
│ ├── shared/ Shared validation, routing, Turnstile, and Graph mail helpers
│ ├── submit-interest/ Form submission function
│ └── tests/ API tests
├── tests/ Frontend tests
├── app.js Browser form logic
├── config.js Public browser configuration
├── index.html Static page
├── squadrons.js Browser squadron directory
└── package.json Test scripts
- Node.js 20 or newer
- Azure Static Web Apps
- Cloudflare Turnstile site and secret keys
- Microsoft Entra app registration with Microsoft Graph
Mail.Sendpermission - A sender mailbox approved for the application
Copy the example settings file before running the API locally:
cp api/local.settings.json.example api/local.settings.jsonSet these values in api/local.settings.json for local development, or as Azure Static Web Apps application settings in production:
| Setting | Purpose |
|---|---|
TURNSTILE_SECRET_KEY |
Cloudflare Turnstile secret key used by the API |
GRAPH_TENANT_ID |
Microsoft Entra tenant ID |
GRAPH_CLIENT_ID |
Microsoft Entra application client ID |
GRAPH_CLIENT_SECRET |
Microsoft Entra application client secret |
GRAPH_SENDER_USER |
Mailbox or user principal name used to send Graph mail |
EMAIL_COPY_ENABLED |
Optional flag to copy additional recipients |
EMAIL_COPY_RECIPIENTS |
Optional comma-separated copy recipients |
EMAIL_OVERRIDE_ENABLED |
Optional flag to route all mail to test recipients |
EMAIL_OVERRIDE_RECIPIENTS |
Optional comma-separated test recipients |
The browser Turnstile site key is configured in config.js. Site keys are public; secret keys must only be stored in local settings, Azure application settings, or GitHub secrets.
Install dependencies:
npm install
cd api
npm installRun tests from the repository root:
npm testRun only frontend or API tests:
npm run test:frontend
npm run test:apiThe repository includes a GitHub Actions workflow for Azure Static Web Apps. Pushes to main run the test suite and deploy the static site and API when the workflow succeeds.
Required deployment values should be stored as Azure Static Web Apps application settings or GitHub environment secrets, not in source control.
The API logs one structured telemetry record after a form submission passes validation, passes Turnstile, and email delivery is accepted by Microsoft Graph. The log excludes applicant names, email addresses, phone numbers, and message text.
The structured payload is logged with this event name:
CAPResponseForm.SubmissionAccepted
Production deployments should configure an Application Insights resource and set APPLICATIONINSIGHTS_CONNECTION_STRING on the Static Web App API settings so Azure Functions logs are queryable in Azure Monitor.
Use this KQL query in Application Insights Logs to report submissions by squadron:
traces
| where timestamp >= ago(90d)
| extend payload = parse_json(message)
| where payload.eventName == "CAPResponseForm.SubmissionAccepted"
| summarize Submissions = count()
by SquadronCode = tostring(payload.squadronCode),
SquadronName = tostring(payload.squadronName)
| order by Submissions desc, SquadronCode ascFor a month-by-month commander's call trend:
traces
| where timestamp >= startofmonth(ago(180d))
| extend payload = parse_json(message)
| where payload.eventName == "CAPResponseForm.SubmissionAccepted"
| summarize Submissions = count()
by Month = startofmonth(timestamp),
SquadronCode = tostring(payload.squadronCode),
SquadronName = tostring(payload.squadronName)
| order by Month asc, SquadronCode ascMicrosoft Graph Mail.Send application permission is tenant-wide by default: without additional Exchange Online controls, an app with this permission may be able to send as any mailbox in the tenant.
Production deployments should restrict the app to the configured sender mailbox with an Exchange Online application access policy:
Connect-ExchangeOnline -UserPrincipalName admin@example.org
$appId = "<graph-client-id>"
$senderMailbox = "sender@example.org"
$scopeAddress = "capresponseform-mailsend-scope@example.org"
New-DistributionGroup `
-Name "CAPResponseForm Mail.Send Scope" `
-Alias "CAPResponseForm-MailSend-Scope" `
-Type Security `
-PrimarySmtpAddress $scopeAddress
Add-DistributionGroupMember `
-Identity $scopeAddress `
-Member $senderMailbox `
-BypassSecurityGroupManagerCheck
New-ApplicationAccessPolicy `
-AppId $appId `
-PolicyScopeGroupId $scopeAddress `
-AccessRight RestrictAccess `
-Description "Restrict CAPResponseForm Mail.Send to the configured sender mailbox."Verify the effective scope with Test-ApplicationAccessPolicy:
Test-ApplicationAccessPolicy -AppId $appId -Identity $senderMailbox
Test-ApplicationAccessPolicy -AppId $appId -Identity someone-else@example.orgExpected results:
- The configured sender mailbox returns
Granted. - A mailbox outside the scope group returns
Denied.
- Do not commit
api/local.settings.json. - Do not commit Graph client secrets, Turnstile secret keys, deployment tokens, or mailbox credentials.
- Restrict the Microsoft Graph mail application to the smallest practical mailbox scope with an Exchange Online application access policy.
- Use email override settings for testing in non-production environments.
- Review public examples and tests before adding real personal addresses or tenant-specific values.
No license has been selected for this repository yet.