diff --git a/.gitignore b/.gitignore
index a70fa9b..affa59b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ node_modules
public/
static/admin/*.bundle.*
.DS_Store
+.env
yarn-error.log
gatsby-types.d.ts
diff --git a/emails/contact/index.html b/emails/contact/index.html
new file mode 100644
index 0000000..118fa0b
--- /dev/null
+++ b/emails/contact/index.html
@@ -0,0 +1,31 @@
+
+
+
+
New message from the Idea Board
+
From: {{senderName}} ({{senderEmail}})
+
Regarding idea: {{ideaTitle}}
+
+
{{message}}
+
+
+ You can reply directly to {{senderName}} at
+ {{senderEmail}}.
+
+
+
+
diff --git a/gatsby-node.js b/gatsby-node.js
index d441165..91c45a7 100644
--- a/gatsby-node.js
+++ b/gatsby-node.js
@@ -6,6 +6,7 @@ const {
stringWithDefault,
resolveToArray,
resourceQuery,
+ alleniteQuery,
} = require("./gatsby/utils/gatsby-resolver-utils");
const {
RESOURCES_GATSBY_NODE_KEY,
@@ -70,7 +71,24 @@ exports.createResolvers = ({ reporter, createResolvers }) => {
),
},
authors: {
- resolve: (source) => resolveToArray(source.authors),
+ resolve: async (source, _args, context) => {
+ const names = resolveToArray(source.authors);
+ const results = await Promise.all(
+ names
+ .filter(Boolean)
+ .map((name) =>
+ context.nodeModel.findOne(alleniteQuery(name)),
+ ),
+ );
+ return results.filter(Boolean);
+ },
+ },
+ primaryContact: {
+ resolve: async (source, _args, context) => {
+ const query = alleniteQuery(source.primaryContact);
+ if (!query) return null;
+ return context.nodeModel.findOne(query);
+ },
},
title: {
resolve: (source) =>
@@ -128,8 +146,9 @@ exports.createPages = ({ actions, graphql }) => {
// Create pages for any markdown files that are configured to have their
// own node type (e.g. Resource) based on their templateKey.
- const typedNodePages = Object.keys(TEMPLATE_KEY_TO_TYPE).map(
- (templateKey) => {
+ const typedNodePages = Object.keys(TEMPLATE_KEY_TO_TYPE)
+ .filter((templateKey) => !DATA_ONLY_PAGES.includes(templateKey))
+ .map((templateKey) => {
const nodeKey = TEMPLATE_KEY_TO_TYPE[templateKey];
const allKeyString = `all${nodeKey}`;
return graphql(`
diff --git a/gatsby/constants.js b/gatsby/constants.js
index d086e08..385c963 100644
--- a/gatsby/constants.js
+++ b/gatsby/constants.js
@@ -3,6 +3,7 @@ const PROGRAM_TEMPLATE_KEY = `program`;
const RESOURCES_TEMPLATE_KEY = `resource`;
const RESOURCES_GATSBY_NODE_KEY = `Resource`;
+const ALLENITE_GATSBY_NODE_KEY = `Allenite`;
const MARKDOWN_REMARK_GATSBY_NODE_KEY = `MarkdownRemark`;
/**
@@ -14,6 +15,7 @@ const MARKDOWN_REMARK_GATSBY_NODE_KEY = `MarkdownRemark`;
*/
const TEMPLATE_KEY_TO_TYPE = {
[RESOURCES_TEMPLATE_KEY]: RESOURCES_GATSBY_NODE_KEY,
+ [ALLENITE_TEMPLATE_KEY]: ALLENITE_GATSBY_NODE_KEY,
};
module.exports = {
ALLENITE_TEMPLATE_KEY,
@@ -22,4 +24,5 @@ module.exports = {
MARKDOWN_REMARK_GATSBY_NODE_KEY,
TEMPLATE_KEY_TO_TYPE,
RESOURCES_GATSBY_NODE_KEY,
+ ALLENITE_GATSBY_NODE_KEY,
};
diff --git a/gatsby/schema/base.gql b/gatsby/schema/base.gql
index 6aafc1d..c692e8a 100644
--- a/gatsby/schema/base.gql
+++ b/gatsby/schema/base.gql
@@ -14,13 +14,13 @@ type Frontmatter {
description: String
draft: Boolean
tags: [String!]
- authors: [String!]!
+ authors: [Allenite!]!
nextSteps: String
program: [String!]
publication: String
introduction: String
resources: [Resource!]!
- primaryContact: String
+ primaryContact: Allenite
}
type Resource implements Node {
@@ -35,4 +35,13 @@ type Resource implements Node {
date: Date @dateformat
tags: [String!]
draft: Boolean
-}
\ No newline at end of file
+}
+
+type Allenite implements Node {
+ slug: String!
+ name: String!
+ contactId: String
+ position: String
+ contact: String
+ program: [String!]
+}
diff --git a/gatsby/utils/gatsby-resolver-utils.js b/gatsby/utils/gatsby-resolver-utils.js
index 71099c4..a14bef0 100644
--- a/gatsby/utils/gatsby-resolver-utils.js
+++ b/gatsby/utils/gatsby-resolver-utils.js
@@ -1,6 +1,7 @@
const {
RESOURCES_TEMPLATE_KEY,
TEMPLATE_KEY_TO_TYPE,
+ ALLENITE_TEMPLATE_KEY,
} = require("../constants");
const slugify = require("slugify");
@@ -47,23 +48,30 @@ const resolveSlug = (id, directory) => {
};
/**
- * Builds a nodeModel query for a single Resource node by display name.
- * Returns null if the name can't be slugified (falsy input).
- * @param {string|null|undefined} name - The resource's name (e.g., "Software Y")
- * @returns {{ query: object, type: string } | null}
+ * Builds a nodeModel query for a single node by display name and template key.
+ * Uses slugs in place of names to prevent namespace collisions when querying
+ * via Gatsby's nodeModel. Returns null if the name can't be slugified (falsy input).
+ * @param {string|null|undefined} name - The node's display name (e.g., "Software Y", "Jane Smith")
+ * @param {string} templateKey - The template key used for slug resolution and type lookup (e.g., RESOURCES_TEMPLATE_KEY)
+ * @returns {{ query: object, type: string } | null} A nodeModel-compatible query/type pair, or null
*/
-const resourceQuery = (name) => {
- const slug = resolveSlug(name, RESOURCES_TEMPLATE_KEY);
+const buildNodeQuery = (name, templateKey) => {
+ const slug = resolveSlug(name, templateKey);
if (!slug) return null;
return {
query: { filter: { slug: { eq: slug } } },
- type: TEMPLATE_KEY_TO_TYPE[RESOURCES_TEMPLATE_KEY], // "Resource"
+ type: TEMPLATE_KEY_TO_TYPE[templateKey],
};
};
+const resourceQuery = (name) => buildNodeQuery(name, RESOURCES_TEMPLATE_KEY);
+const alleniteQuery = (name) => buildNodeQuery(name, ALLENITE_TEMPLATE_KEY);
+
module.exports = {
stringWithDefault,
resolveToArray,
resolveSlug,
resourceQuery,
+ alleniteQuery,
+ buildNodeQuery,
};
diff --git a/netlify.toml b/netlify.toml
index 6b324a0..a58b6c4 100644
--- a/netlify.toml
+++ b/netlify.toml
@@ -1,6 +1,10 @@
[build]
publish = "public"
command = "npm run build"
+ package = "@netlify/plugin-gatsby"
+[[plugins]]
+ package = "@netlify/plugin-emails"
+
[build.environment]
NODE_VERSION = "22.16.0"
YARN_VERSION = "1.22.4"
diff --git a/netlify/functions/contact.mts b/netlify/functions/contact.mts
index 66db490..6e711db 100644
--- a/netlify/functions/contact.mts
+++ b/netlify/functions/contact.mts
@@ -1,24 +1,206 @@
-import type { Handler } from "@netlify/functions";
+/**
+ * Contact form handler.
+ *
+ * Recipient email lookup:
+ * recipientId from the request body is matched against the `id` column of a
+ * Google Sheet to resolve the recipient's email. The sheet is expected to
+ * have a header row and columns: A=id, B=name, C=email, D=githubid.
+ *
+ * Required env vars:
+ * CONTACTS_SHEET_ID — spreadsheet ID (from the sheet URL)
+ * GOOGLE_SERVICE_ACCOUNT_EMAIL — service account client_email
+ * GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY — service account private_key (PEM)
+ *
+ * The sheet must be shared (at least Viewer) with the service account email.
+ *
+ * Email sending:
+ * Uses the Netlify Email Integration with Mailgun. The integration must be
+ * configured with the required env vars.
+ * see: https://docs.netlify.com/extend/install-and-use/setup-guides/email-integration/#required-environment-variables
+ * The email template lives at emails/contact/index.html.
+ */
-const handler: Handler = async function (event) {
- if (event.body === null) {
- return {
- statusCode: 400,
- body: JSON.stringify("Payload required"),
- };
+import { createSign } from "node:crypto";
+
+interface ContactRequest {
+ senderName: string;
+ senderEmail: string;
+ recipientName: string;
+ recipientId: string;
+ message: string;
+ ideaTitle?: string;
+}
+
+// user@domain.tld — no whitespace, requires exactly one @, at least one dot in domain
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+// CR/LF in header-interpolated fields enables email header injection
+const HEADER_INJECTION_RE = /[\r\n]/;
+
+function validateRequest(body: unknown): body is ContactRequest {
+ if (typeof body !== "object" || body === null) return false;
+ const b = body as Record;
+ return (
+ typeof b.senderName === "string" && b.senderName.length > 0 &&
+ !HEADER_INJECTION_RE.test(b.senderName) &&
+ typeof b.senderEmail === "string" && EMAIL_RE.test(b.senderEmail) &&
+ typeof b.recipientName === "string" && b.recipientName.length > 0 &&
+ typeof b.recipientId === "string" && b.recipientId.length > 0 &&
+ typeof b.message === "string" && b.message.length > 0
+ );
+}
+
+async function getSheetsAccessToken(): Promise {
+ const clientEmail = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL;
+ const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY;
+ if (!clientEmail || !rawKey) {
+ throw new Error("Google service account credentials missing");
}
+ // Netlify may store the key with literal \n escapes; normalize to real newlines.
+ const privateKey = rawKey.replace(/\\n/g, "\n");
- const requestBody = JSON.parse(event.body) as {
- senderName: string;
- senderEmail: string;
- recipient: string;
- message: string;
+ const now = Math.floor(Date.now() / 1000);
+ const header = { alg: "RS256", typ: "JWT" };
+ const claim = {
+ iss: clientEmail,
+ scope: "https://www.googleapis.com/auth/spreadsheets.readonly",
+ aud: "https://oauth2.googleapis.com/token",
+ exp: now + 3600,
+ iat: now,
};
+ const encode = (obj: object) =>
+ Buffer.from(JSON.stringify(obj)).toString("base64url");
+ const unsigned = `${encode(header)}.${encode(claim)}`;
+ const signer = createSign("RSA-SHA256");
+ signer.update(unsigned);
+ const signature = signer.sign(privateKey).toString("base64url");
+ const jwt = `${unsigned}.${signature}`;
- return {
- statusCode: 200,
- body: JSON.stringify(requestBody),
- };
-};
+ const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ assertion: jwt,
+ }),
+ });
+ if (!tokenRes.ok) {
+ throw new Error(`Google token exchange failed: ${tokenRes.status}`);
+ }
+ const data = (await tokenRes.json()) as { access_token?: string };
+ if (!data.access_token) {
+ throw new Error("Google token response missing access_token");
+ }
+ return data.access_token;
+}
+
+async function lookupRecipientEmail(recipientId: string): Promise {
+ const sheetId = process.env.CONTACTS_SHEET_ID;
+ if (!sheetId) throw new Error("CONTACTS_SHEET_ID missing");
+ const token = await getSheetsAccessToken();
+ const range = encodeURIComponent("Sheet1!A:C");
+ const res = await fetch(
+ `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}`,
+ { headers: { Authorization: `Bearer ${token}` } }
+ );
+ if (!res.ok) {
+ throw new Error(`Sheets read failed: ${res.status}`);
+ }
+ const data = (await res.json()) as { values?: string[][] };
+ const rows = data.values ?? [];
+ for (let i = 1; i < rows.length; i++) {
+ const [id, , email] = rows[i];
+ if (id === recipientId && email) return email;
+ }
+ return undefined;
+}
+
+export default async function (request: Request) {
+ if (request.method !== "POST") {
+ return new Response(JSON.stringify("Method not allowed"), { status: 405 });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return new Response(JSON.stringify("Invalid JSON"), { status: 400 });
+ }
+
+ if (!validateRequest(body)) {
+ return new Response(JSON.stringify("Missing or invalid fields"), { status: 400 });
+ }
+
+ let recipientEmail: string | undefined;
+ try {
+ recipientEmail = await lookupRecipientEmail(body.recipientId);
+ } catch (error) {
+ console.error("Contact lookup failed:", error);
+ return new Response(
+ JSON.stringify("Contact lookup failed"),
+ { status: 502 }
+ );
+ }
+ if (!recipientEmail) {
+ return new Response(
+ JSON.stringify("No email stored for submitted contact ID"),
+ { status: 400 }
+ );
+ }
+
+ const baseUrl = new URL(request.url).origin;
+ const emailsSecret = process.env.NETLIFY_EMAILS_SECRET;
+ const from = process.env.NETLIFY_EMAILS_FROM
+ ?? (process.env.NETLIFY_EMAILS_MAILGUN_DOMAIN
+ ? `noreply@${process.env.NETLIFY_EMAILS_MAILGUN_DOMAIN}`
+ : undefined);
+
+ if (!emailsSecret || !from) {
+ console.error("Email service misconfigured:", { emailsSecret: !!emailsSecret, from: !!from });
+ return new Response(
+ JSON.stringify("Email service not configured"),
+ { status: 500 }
+ );
+ }
+
+ let emailResponse: Response;
+ try {
+ emailResponse = await fetch(
+ `${baseUrl}/.netlify/functions/emails/contact`,
+ {
+ method: "POST",
+ headers: {
+ "netlify-emails-secret": emailsSecret,
+ },
+ body: JSON.stringify({
+ from,
+ reply_to: body.senderEmail,
+ to: recipientEmail,
+ subject: `Idea Board: message from ${body.senderName}`,
+ parameters: {
+ senderName: body.senderName,
+ senderEmail: body.senderEmail,
+ message: body.message,
+ ideaTitle: body.ideaTitle ?? "N/A",
+ },
+ }),
+ }
+ );
+ } catch (error) {
+ console.error("Error calling email function:", error);
+ return new Response(
+ JSON.stringify("Failed to send email"),
+ { status: 502 }
+ );
+ }
+
+ if (!emailResponse.ok) {
+ const errorText = await emailResponse.text();
+ console.error("Email send failed:", emailResponse.status, errorText);
+ return new Response(
+ JSON.stringify("Failed to send email"),
+ { status: 502 }
+ );
+ }
-export { handler };
+ return new Response(JSON.stringify("Message sent"), { status: 200 });
+}
diff --git a/package.json b/package.json
index 8867761..a3e81f8 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,8 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+ "@netlify/plugin-emails": "^1.1.1",
+ "@netlify/plugin-gatsby": "^3.8.4",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/antd": "^1.0.0",
"@types/node": "^20.11.20",
@@ -80,4 +82,4 @@
"engines": {
"node": ">= 18.15.0"
}
-}
\ No newline at end of file
+}
diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx
index 727a1c0..3a1dd75 100644
--- a/src/components/ContactModal.tsx
+++ b/src/components/ContactModal.tsx
@@ -1,13 +1,16 @@
import React, { useState } from "react";
+import { graphql, useStaticQuery } from "gatsby";
+
import { Button, Flex, Input, Modal } from "antd";
import { CONTACT_FUNCTION_PATH } from "../constants";
+import { Allenite } from "../types";
interface ContactModalProps {
- authors: readonly (string | null)[] | null | undefined;
+ authors: ReadonlyArray | null;
open: boolean;
- primaryContact: string | null | undefined;
+ primaryContact: Allenite | null;
title: string;
onClose: () => void;
}
@@ -23,20 +26,38 @@ export const ContactModal: React.FC = ({
const [senderEmail, setSenderEmail] = useState("");
const [message, setMessage] = useState("");
+ const defaultContactQueryData = useStaticQuery(graphql`
+ query DefaultContact {
+ allenite(name: { eq: "Idea Board" }) {
+ name
+ contactId
+ }
+ }
+ `);
+
const hasPrimaryContact = !!primaryContact;
const hasAuthors = !!authors && authors.length > 0;
const filteredAuthors =
- authors?.filter(Boolean).join(", ") ?? "the authors";
+ authors
+ ?.map((author) => author.name)
+ .filter(Boolean)
+ .join(", ") ?? "the authors";
- const recipientLabel = hasPrimaryContact
+ const defaultContact = defaultContactQueryData.allenite;
+ const preferredRecipient = hasPrimaryContact
? primaryContact
: hasAuthors
- ? filteredAuthors
- : // TODO use real contact info
- "fake default email inbox";
+ ? authors[0]
+ : null;
+ const recipient = preferredRecipient?.contactId
+ ? preferredRecipient
+ : defaultContact;
+
+ const hasRecipient = !!recipient?.name && !!recipient?.contactId;
const handleSubmit = async () => {
+ if (!hasRecipient) return;
try {
const response = await fetch(CONTACT_FUNCTION_PATH, {
method: "POST",
@@ -46,8 +67,10 @@ export const ContactModal: React.FC = ({
body: JSON.stringify({
senderName: senderName,
senderEmail: senderEmail,
- recipient: recipientLabel,
+ recipientName: recipient.name,
+ recipientId: recipient.contactId,
message: message,
+ ideaTitle: title,
}),
});
@@ -70,7 +93,12 @@ export const ContactModal: React.FC = ({
,
-