Email delivery: /api/preview-link admin endpoint + EMAIL_DELIVERY.md guide
This commit is contained in:
207
EMAIL_DELIVERY.md
Normal file
207
EMAIL_DELIVERY.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Email link delivery
|
||||||
|
|
||||||
|
The check-in form is reached via a personalized URL that carries the
|
||||||
|
contact's CiviCRM ID + a server-issued checksum:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://check-in.fci.coop/?cid=<contact_id>&cs=<checksum>
|
||||||
|
```
|
||||||
|
|
||||||
|
This document covers the three pieces needed to actually get those links
|
||||||
|
into form-fillers' hands:
|
||||||
|
|
||||||
|
1. A **CiviCRM message template** that bakes the URL into an email body.
|
||||||
|
2. A way to **trigger sending** (one of three options below).
|
||||||
|
3. A **link-preview endpoint** in this app for ad-hoc / testing use.
|
||||||
|
|
||||||
|
You don't need all three — most teams use #1 + the simplest version of #2.
|
||||||
|
The preview endpoint (#3) is a debugging convenience.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create the CiviCRM message template
|
||||||
|
|
||||||
|
In CiviCRM:
|
||||||
|
|
||||||
|
1. Navigate to **Mailings → Message Templates → Add Message Template**.
|
||||||
|
2. Title: `Co-op Check-in invitation`
|
||||||
|
3. Subject:
|
||||||
|
```
|
||||||
|
Time for your co-op check-in
|
||||||
|
```
|
||||||
|
4. Plain-text body (substitute your real domain):
|
||||||
|
|
||||||
|
```
|
||||||
|
Hello {contact.display_name},
|
||||||
|
|
||||||
|
Please take a few minutes to update us on your co-op's progress this
|
||||||
|
month. The form pre-fills your prior responses — you only need to update
|
||||||
|
what's changed.
|
||||||
|
|
||||||
|
Open your check-in:
|
||||||
|
https://check-in.fci.coop/?cid={contact.contact_id}&cs={contact.checksum}
|
||||||
|
|
||||||
|
The link is personalized to you and expires in 14 days. If you no longer
|
||||||
|
have it, reply to this email and we'll send a fresh one.
|
||||||
|
|
||||||
|
With gratitude,
|
||||||
|
The Food Co-op Initiative team
|
||||||
|
```
|
||||||
|
|
||||||
|
5. HTML body (same content, slightly nicer):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<p>Hello {contact.display_name},</p>
|
||||||
|
|
||||||
|
<p>Please take a few minutes to update us on your co-op's progress this
|
||||||
|
month. The form pre-fills your prior responses — you only need to update
|
||||||
|
what's changed.</p>
|
||||||
|
|
||||||
|
<p style="margin: 24px 0;">
|
||||||
|
<a href="https://check-in.fci.coop/?cid={contact.contact_id}&cs={contact.checksum}"
|
||||||
|
style="display: inline-block; background: #3a5520; color: #fafaf7;
|
||||||
|
padding: 10px 20px; border-radius: 6px; text-decoration: none;
|
||||||
|
font-weight: 500;">
|
||||||
|
Open your check-in
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size: 13px; color: #5b574b;">The link is personalized to
|
||||||
|
you and expires in 14 days. If you no longer have it, reply to this email
|
||||||
|
and we'll send a fresh one.</p>
|
||||||
|
|
||||||
|
<p>With gratitude,<br />The Food Co-op Initiative team</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Save.
|
||||||
|
|
||||||
|
> **Token reference:**
|
||||||
|
> - `{contact.display_name}` — the form-filler's name
|
||||||
|
> - `{contact.contact_id}` — their CiviCRM ID (becomes `cid`)
|
||||||
|
> - `{contact.checksum}` — a fresh secure token (becomes `cs`)
|
||||||
|
>
|
||||||
|
> All three are stock CiviCRM tokens. The checksum is generated at send time
|
||||||
|
> and includes the recipient's contact-specific hash, so it's both
|
||||||
|
> per-contact and per-send.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Trigger sending — three options
|
||||||
|
|
||||||
|
### Option A — Manual: stock CiviCRM Send Email (simplest)
|
||||||
|
|
||||||
|
Best for: low volume, one-at-a-time, or you want to review each send.
|
||||||
|
|
||||||
|
1. Open the **organization's contact record** in CiviCRM.
|
||||||
|
2. **Relationships** tab → find the row with **"Primary Contact"** → click the related individual's name.
|
||||||
|
3. On the individual's contact view: **Actions → Send Email**.
|
||||||
|
4. **Use Template**: select `Co-op Check-in invitation`. The subject and body populate; tokens render in the preview.
|
||||||
|
5. **Send**. CiviCRM logs the email as an Activity on the contact.
|
||||||
|
|
||||||
|
Repeat per contact. Slow but bulletproof.
|
||||||
|
|
||||||
|
### Option B — Bulk: CiviMail (campaign-style send)
|
||||||
|
|
||||||
|
Best for: send the check-in invitation to every active co-op at once (e.g. monthly).
|
||||||
|
|
||||||
|
1. Build a Smart Group of contacts whose Primary Contact relationships point at orgs in active stages. Example query:
|
||||||
|
- **Contacts** → **Advanced Search**
|
||||||
|
- Add criteria: `Has relationship` → `Primary Contact` → status: Active
|
||||||
|
- Optionally constrain by the related org's `Food_Co_op_Organizing.Stage` field
|
||||||
|
- Save as Smart Group: "Active co-op primary contacts"
|
||||||
|
|
||||||
|
2. **Mailings → New Mailing**.
|
||||||
|
3. Recipients: the smart group above.
|
||||||
|
4. Choose template: `Co-op Check-in invitation`.
|
||||||
|
5. Schedule send.
|
||||||
|
|
||||||
|
Each recipient gets their own checksum embedded in the URL — CiviMail
|
||||||
|
expands the tokens per-recipient.
|
||||||
|
|
||||||
|
### Option C — One-click: CiviRules action (most polished)
|
||||||
|
|
||||||
|
Best for: a button-on-the-org-record workflow.
|
||||||
|
|
||||||
|
Setup:
|
||||||
|
|
||||||
|
1. **Administer → CiviRules → Manage Rules → New Rule**.
|
||||||
|
2. Title: `Send Co-op Check-in invitation`.
|
||||||
|
3. **Trigger**: pick a "manually trigger from org row" action if your CiviRules version supports it. Otherwise: trigger on org Stage change (auto-fires when staff advance an org).
|
||||||
|
4. **Action**: `Send email to contact via message template`.
|
||||||
|
- Template: `Co-op Check-in invitation`.
|
||||||
|
- Recipient: **Contact in relationship** → "Primary Contact" → side B (the Individual).
|
||||||
|
5. Save.
|
||||||
|
|
||||||
|
When staff change `Food_Co_op_Organizing.Stage` on an org, the rule fires
|
||||||
|
and emails the org's Primary Contact automatically — no extra clicks.
|
||||||
|
|
||||||
|
### Combination
|
||||||
|
|
||||||
|
You can use any combination. Common pattern: Option C for stage transitions
|
||||||
|
(automatic) + Option A for ad-hoc resends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The `/api/preview-link` endpoint (admin debugging)
|
||||||
|
|
||||||
|
Generates a working URL for any contact, returning the URL and the
|
||||||
|
contact/org context for verification.
|
||||||
|
|
||||||
|
**Auth:** requires the `HEALTH_TOKEN` env var (same secret as
|
||||||
|
`/api/health`). In development without `HEALTH_TOKEN` set, no auth required.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://check-in.fci.coop/api/preview-link?cid=513&token=$HEALTH_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contactId": 513,
|
||||||
|
"contactName": "Bob Sample",
|
||||||
|
"orgId": 9609,
|
||||||
|
"orgName": "A Sample Food Co-op",
|
||||||
|
"orgStage": "Organizing",
|
||||||
|
"url": "https://check-in.fci.coop/?cid=513&cs=8d1f...",
|
||||||
|
"checksum": "8d1f...",
|
||||||
|
"ttlHours": 336
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
|
||||||
|
- **Testing**: confirm a contact's setup before sending the real CiviCRM email.
|
||||||
|
- **Manual / external sends**: copy the URL into Slack, ad-hoc email, an SMS, etc.
|
||||||
|
- **Debugging**: if a contact reports the form not working, generate a fresh URL and try it yourself.
|
||||||
|
- **Bulk export**: script-loop over a list of cids to produce a CSV of `(name, org, url)` for an external mail-merge.
|
||||||
|
|
||||||
|
**Errors you may see:**
|
||||||
|
|
||||||
|
- `404 — has no active Primary Contact relationship` → the contact isn't linked to any org.
|
||||||
|
- `409 — multiple active relationships` → resolve in CiviCRM first; the form needs exactly one.
|
||||||
|
- `501 — Stub mode active` → you forgot to set `CIVI_BASE_URL` etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational runbook
|
||||||
|
|
||||||
|
### "We need to send invitations for this month"
|
||||||
|
|
||||||
|
1. (Bulk path) Use Option B: build/refresh the Smart Group, schedule the CiviMail mailing, send.
|
||||||
|
2. (Per-org path) Use Option A: open each org → Send Email to Primary Contact.
|
||||||
|
3. (Auto path) Use Option C: just advance stages; emails go out automatically.
|
||||||
|
|
||||||
|
### "Contact reports the link doesn't work"
|
||||||
|
|
||||||
|
1. Hit `/api/preview-link?cid=<their-cid>&token=<HEALTH_TOKEN>`.
|
||||||
|
2. Verify the response shows the right org name + stage.
|
||||||
|
3. Send the URL from the response directly to the contact (or have them try it).
|
||||||
|
4. If `/api/preview-link` 404s, the contact lacks a Primary Contact relationship — fix in CiviCRM first.
|
||||||
|
5. If their old URL truly expired (older than 14 days / your `cs_offset` setting), CiviCRM rejects it. The new one from `preview-link` will work.
|
||||||
|
|
||||||
|
### "We want to invalidate all outstanding links for one contact"
|
||||||
|
|
||||||
|
In CiviCRM, edit the contact's **hash** field (under their record → Edit). Bumping the hash invalidates every checksum issued for that contact. Use as a break-glass for compromised links.
|
||||||
143
app/api/preview-link/route.ts
Normal file
143
app/api/preview-link/route.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/preview-link?cid=<contactId>&token=<HEALTH_TOKEN>
|
||||||
|
*
|
||||||
|
* Returns a ready-to-send check-in URL for a given contact, plus enough
|
||||||
|
* context for staff to verify it's the right org. Used for:
|
||||||
|
* - Generating links to paste into ad-hoc emails (or external tools)
|
||||||
|
* - Testing a contact's setup before sending the real CiviCRM email
|
||||||
|
* - QA before rollout
|
||||||
|
*
|
||||||
|
* AUTH: Always requires the HEALTH_TOKEN (same secret as /api/health). In
|
||||||
|
* development, if HEALTH_TOKEN is unset, anyone can hit the endpoint —
|
||||||
|
* matches /api/health behavior so dev workflow stays frictionless. In
|
||||||
|
* production, the endpoint returns 404 if HEALTH_TOKEN is unset.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* {
|
||||||
|
* contactId, contactName,
|
||||||
|
* orgId, orgName, orgStage,
|
||||||
|
* url, // the full form URL with cid + cs
|
||||||
|
* checksum,
|
||||||
|
* ttlHours // how long the checksum is valid (Civi default 14d)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { civi } from "@/lib/civicrm";
|
||||||
|
import { FORM_CONTACT_RELATIONSHIP } from "@/config/form";
|
||||||
|
import { appEnv } from "@/lib/env";
|
||||||
|
|
||||||
|
interface PreviewLinkResponse {
|
||||||
|
contactId: number;
|
||||||
|
contactName: string;
|
||||||
|
orgId: number;
|
||||||
|
orgName: string;
|
||||||
|
orgStage: string;
|
||||||
|
url: string;
|
||||||
|
checksum: string;
|
||||||
|
ttlHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const env = appEnv();
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const cid = url.searchParams.get("cid");
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
|
||||||
|
// Auth gate: production requires HEALTH_TOKEN. Dev allows passthrough.
|
||||||
|
if (env.isProduction) {
|
||||||
|
if (!env.healthToken) return new NextResponse("Not Found", { status: 404 });
|
||||||
|
if (token !== env.healthToken) return new NextResponse("Not Found", { status: 404 });
|
||||||
|
} else if (env.healthToken && token !== env.healthToken) {
|
||||||
|
return new NextResponse("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cid) {
|
||||||
|
return NextResponse.json({ error: "Missing cid parameter." }, { status: 400 });
|
||||||
|
}
|
||||||
|
const contactId = Number(cid);
|
||||||
|
if (!Number.isFinite(contactId)) {
|
||||||
|
return NextResponse.json({ error: "cid must be an integer." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.isStub) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Stub mode active — set CIVI_* env vars to generate real links." },
|
||||||
|
{ status: 501 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve org via the Primary Contact relationship.
|
||||||
|
const relRes = await civi<{ contact_id_b: number }>("Relationship", "get", {
|
||||||
|
select: ["contact_id_b"],
|
||||||
|
where: [
|
||||||
|
["contact_id_a", "=", contactId],
|
||||||
|
["relationship_type_id.name_a_b", "=", FORM_CONTACT_RELATIONSHIP],
|
||||||
|
["is_active", "=", true],
|
||||||
|
],
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
const orgs = relRes.values ?? [];
|
||||||
|
if (orgs.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Contact ${contactId} has no active "${FORM_CONTACT_RELATIONSHIP}" relationship to an organization.`,
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (orgs.length > 1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Contact ${contactId} has multiple active "${FORM_CONTACT_RELATIONSHIP}" relationships. Resolve in CiviCRM first.`,
|
||||||
|
},
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const orgId = orgs[0].contact_id_b;
|
||||||
|
|
||||||
|
// Pull contact + org names + the org's current stage so the response gives
|
||||||
|
// staff everything they need to confirm "yes, this is the right link."
|
||||||
|
const [contactRes, orgRes, csRes] = await Promise.all([
|
||||||
|
civi<{ display_name: string }>("Contact", "get", {
|
||||||
|
select: ["display_name"],
|
||||||
|
where: [["id", "=", contactId]],
|
||||||
|
}),
|
||||||
|
civi<{ display_name: string; "Food_Co_op_Organizing.Stage": string | null }>("Contact", "get", {
|
||||||
|
select: ["display_name", "Food_Co_op_Organizing.Stage"],
|
||||||
|
where: [["id", "=", orgId]],
|
||||||
|
}),
|
||||||
|
civi<{ checksum: string }>("Contact", "getChecksum", { contactId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contactName = contactRes.values?.[0]?.display_name ?? `Contact ${contactId}`;
|
||||||
|
const orgName = orgRes.values?.[0]?.display_name ?? `Organization ${orgId}`;
|
||||||
|
const orgStage = orgRes.values?.[0]?.["Food_Co_op_Organizing.Stage"] ?? "";
|
||||||
|
const checksum = csRes.values?.[0]?.checksum;
|
||||||
|
if (!checksum) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Could not generate a checksum for this contact." },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the URL. Prefer the explicit PUBLIC_ORIGIN env var; fall back to
|
||||||
|
// the request's own origin (handy for dev / first-deploy).
|
||||||
|
const origin =
|
||||||
|
env.publicOrigin ?? `${url.protocol}//${url.host}`;
|
||||||
|
const formUrl = new URL("/", origin);
|
||||||
|
formUrl.searchParams.set("cid", String(contactId));
|
||||||
|
formUrl.searchParams.set("cs", checksum);
|
||||||
|
|
||||||
|
const payload: PreviewLinkResponse = {
|
||||||
|
contactId,
|
||||||
|
contactName,
|
||||||
|
orgId,
|
||||||
|
orgName,
|
||||||
|
orgStage: orgStage ?? "",
|
||||||
|
url: formUrl.toString(),
|
||||||
|
checksum,
|
||||||
|
ttlHours: 14 * 24, // Civi default — adjust if your install changed cs_offset.
|
||||||
|
};
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user