1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 04:45:22 -04:00

use standard email/SMTP for contact form instead of airtable API

This commit is contained in:
Jake Jarvis 2024-02-27 17:34:48 -05:00
parent 2de3914449
commit 4bef13f4ab
Signed by: jake
SSH Key Fingerprint: SHA256:nCkvAjYA6XaSPUqc4TfbBQTpzr8Xj7ritg/sGInCdkc
11 changed files with 102 additions and 110 deletions

View File

@ -1,10 +1,9 @@
# absolutely required:
NEXT_PUBLIC_BASE_URL=
DATABASE_URL=
GH_PUBLIC_TOKEN=
POSTGRES_URL=
POSTGRES_URL_NON_POOLING=
# optional (but not really):
AIRTABLE_API_KEY=
AIRTABLE_BASE=
HCAPTCHA_SECRET_KEY=
MAILGUN_SMTP_USER=
MAILGUN_SMTP_PASS=

View File

@ -5,7 +5,7 @@
[![Licensed under CC-BY-4.0](https://img.shields.io/badge/license-CC--BY--4.0-fb7828?logo=creative-commons&logoColor=white)](LICENSE)
[![GitHub repo size](https://img.shields.io/github/repo-size/jakejarvis/jarv.is?color=009cdf&label=repo%20size&logo=git&logoColor=white)](https://github.com/jakejarvis/jarv.is)
My humble abode on the World Wide Web, created and deployed using [Next.js](https://nextjs.org/), [Stitches](https://stitches.dev/), [Prisma](https://www.prisma.io/), [Vercel](https://vercel.com/), [Supabase](https://supabase.com/), [and more](https://jarv.is/humans.txt).
My humble abode on the World Wide Web, created and deployed using [Next.js](https://nextjs.org/), [Stitches](https://stitches.dev/), [Prisma](https://www.prisma.io/), [Vercel](https://vercel.com/), [PlanetScale](https://planetscale.com/), [and more](https://jarv.is/humans.txt).
I keep an ongoing list of [post ideas](https://github.com/jakejarvis/jarv.is/issues/1) and [coding to-dos](https://github.com/jakejarvis/jarv.is/issues/714) as issues in this repo. Outside contributions, improvements, and/or corrections are welcome too!

View File

@ -4,9 +4,6 @@
// https://nextjs.org/blog/next-9-1-7#new-built-in-polyfills-fetch-url-and-objectassign
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fetcher = async (input: RequestInfo, init?: RequestInit): Promise<any> => {
const res = await fetch(input, init);
return res.json();
};
const fetcher = <T = any>(...args: Parameters<typeof fetch>): Promise<T> => fetch(...args).then((res) => res.json());
export default fetcher;

View File

@ -22,7 +22,7 @@
"@hcaptcha/react-hcaptcha": "^1.10.1",
"@novnc/novnc": "1.4.0",
"@octokit/graphql": "^7.0.2",
"@octokit/graphql-schema": "^14.57.0",
"@octokit/graphql-schema": "^14.58.0",
"@prisma/client": "^5.10.2",
"@react-spring/web": "^9.7.3",
"@stitches/react": "1.3.1-1",
@ -39,6 +39,7 @@
"next": "14.1.0",
"next-mdx-remote": "^4.4.1",
"next-seo": "^6.5.0",
"nodemailer": "^6.9.10",
"obj-str": "^1.1.0",
"p-map": "^7.0.1",
"p-memoize": "^7.1.1",
@ -74,9 +75,10 @@
"@jakejarvis/eslint-config": "^3.1.0",
"@types/comma-number": "^2.1.2",
"@types/node": "^20.11.20",
"@types/nodemailer": "^6.4.14",
"@types/novnc__novnc": "^1.3.4",
"@types/prop-types": "^15.7.11",
"@types/react": "^18.2.58",
"@types/react": "^18.2.60",
"@types/react-dom": "^18.2.19",
"@types/react-is": "^18.2.4",
"@typescript-eslint/eslint-plugin": "^7.1.0",
@ -111,9 +113,9 @@
"eslint"
]
},
"packageManager": "pnpm@8.15.3",
"packageManager": "pnpm@8.15.4",
"volta": {
"node": "20.11.1",
"pnpm": "8.15.3"
"pnpm": "8.15.4"
}
}

View File

@ -1,29 +1,17 @@
import { NextResponse } from "next/server";
import nodemailer from "nodemailer";
import queryString from "query-string";
import { siteDomain, hcaptchaSiteKey } from "../../lib/config";
import type { NextRequest } from "next/server";
import fetcher from "../../lib/helpers/fetcher";
import { siteDomain, authorEmail, hcaptchaSiteKey } from "../../lib/config";
import type { NextApiHandler } from "next";
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
const HCAPTCHA_SITE_KEY = hcaptchaSiteKey || "10000000-ffff-ffff-ffff-000000000001";
const HCAPTCHA_SECRET_KEY = process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000";
const HCAPTCHA_API_ENDPOINT = "https://hcaptcha.com/siteverify";
const { AIRTABLE_API_KEY, AIRTABLE_BASE } = process.env;
const AIRTABLE_API_ENDPOINT = "https://api.airtable.com/v0/";
export const config = {
runtime: "edge",
};
// eslint-disable-next-line import/no-anonymous-default-export
export default async (req: NextRequest) => {
const handler: NextApiHandler = async (req, res) => {
// redirect GET requests to this endpoint to the contact form itself
if (req.method === "GET") {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL || `https://${siteDomain}`}/contact/`);
return res.redirect(`${process.env.NEXT_PUBLIC_BASE_URL || `https://${siteDomain}`}/contact/`);
}
// possible weirdness? https://github.com/orgs/vercel/discussions/78#discussioncomment-5089059
const data = await req.json();
const data = req.body;
// these are both backups to client-side validations just in case someone squeezes through without them. the codes
// are identical so they're caught in the same fashion.
@ -36,61 +24,61 @@ export default async (req: NextRequest) => {
throw new Error("INVALID_CAPTCHA");
}
// sent directly to airtable
const airtableResult = await sendToAirtable({
Name: data.name,
Email: data.email,
Message: data.message,
});
// throw an internal error, not user's fault
if (airtableResult !== true) {
throw new Error("AIRTABLE_API_ERROR");
if (!(await sendMessage(data))) {
throw new Error("NODEMAILER_ERROR");
}
// disable caching on both ends. see:
// https://vercel.com/docs/concepts/functions/edge-functions/edge-caching
res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate");
// success! let the client know
return NextResponse.json(
{ success: true },
{
status: 201,
headers: {
// disable caching on both ends. see:
// https://vercel.com/docs/concepts/functions/edge-functions/edge-caching
"Cache-Control": "private, no-cache, no-store, must-revalidate",
},
}
);
return res.status(201).json({ success: true });
};
const validateCaptcha = async (formResponse: unknown): Promise<unknown> => {
const response = await fetch(HCAPTCHA_API_ENDPOINT, {
const response = await fetcher("https://hcaptcha.com/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: queryString.stringify({
response: formResponse,
sitekey: HCAPTCHA_SITE_KEY,
secret: HCAPTCHA_SECRET_KEY,
// fallback to dummy secret for testing: https://docs.hcaptcha.com/#integration-testing-test-keys
sitekey: hcaptchaSiteKey || "10000000-ffff-ffff-ffff-000000000001",
secret: process.env.HCAPTCHA_SECRET_KEY || "0x0000000000000000000000000000000000000000",
}),
});
const result = await response.json();
return result.success;
return response?.success;
};
const sendToAirtable = async (data: unknown): Promise<boolean> => {
const response = await fetch(`${AIRTABLE_API_ENDPOINT}${AIRTABLE_BASE}/Messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${AIRTABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
fields: data,
}),
});
const sendMessage = async (data: Record<string, unknown>): Promise<boolean> => {
try {
const transporter = nodemailer.createTransport({
service: "mailgun",
auth: {
user: process.env.MAILGUN_SMTP_USER,
pass: process.env.MAILGUN_SMTP_PASS,
},
});
return response.ok;
await transporter.sendMail({
from: `${data.name} <${process.env.MAILGUN_SMTP_USER}>`,
sender: `nodemailer <${process.env.MAILGUN_SMTP_USER}>`,
replyTo: `${data.name} <${data.email}>`,
to: `<${authorEmail}>`,
subject: `[${siteDomain}] Contact Form Submission`,
// TODO: add markdown parsing as promised on the form.
text: `${data.message}`,
});
} catch (error) {
console.error(error);
return false;
}
return true;
};
export default handler;

View File

@ -48,7 +48,7 @@ const Privacy = () => {
<p>
A very simple hit counter on each blog post tallies an aggregate number of pageviews (i.e.{" "}
<CodeInline>hits = hits + 1</CodeInline>) in a <Link href="https://supabase.com/">Supabase Postgres</Link>{" "}
<CodeInline>hits = hits + 1</CodeInline>) in a <Link href="https://planetscale.com/">PlanetScale</Link>{" "}
database. Individual views and identifying (or non-identifying) details are{" "}
<strong>never stored or logged</strong>.
</p>

63
pnpm-lock.yaml generated
View File

@ -18,8 +18,8 @@ dependencies:
specifier: ^7.0.2
version: 7.0.2
'@octokit/graphql-schema':
specifier: ^14.57.0
version: 14.57.0
specifier: ^14.58.0
version: 14.58.0
'@prisma/client':
specifier: ^5.10.2
version: 5.10.2(prisma@5.10.2)
@ -68,6 +68,9 @@ dependencies:
next-seo:
specifier: ^6.5.0
version: 6.5.0(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)
nodemailer:
specifier: ^6.9.10
version: 6.9.10
obj-str:
specifier: ^1.1.0
version: 1.1.0
@ -103,7 +106,7 @@ dependencies:
version: 5.0.1(react@18.2.0)
react-innertext:
specifier: ^1.1.5
version: 1.1.5(@types/react@18.2.58)(react@18.2.0)
version: 1.1.5(@types/react@18.2.60)(react@18.2.0)
react-intersection-observer:
specifier: ^9.8.1
version: 9.8.1(react-dom@18.2.0)(react@18.2.0)
@ -115,7 +118,7 @@ dependencies:
version: 2.14.1(react@18.2.0)
react-textarea-autosize:
specifier: ^8.5.3
version: 8.5.3(@types/react@18.2.58)(react@18.2.0)
version: 8.5.3(@types/react@18.2.60)(react@18.2.0)
react-tweet:
specifier: ^3.2.0
version: 3.2.0(react-dom@18.2.0)(react@18.2.0)
@ -174,6 +177,9 @@ devDependencies:
'@types/node':
specifier: ^20.11.20
version: 20.11.20
'@types/nodemailer':
specifier: ^6.4.14
version: 6.4.14
'@types/novnc__novnc':
specifier: ^1.3.4
version: 1.3.4
@ -181,8 +187,8 @@ devDependencies:
specifier: ^15.7.11
version: 15.7.11
'@types/react':
specifier: ^18.2.58
version: 18.2.58
specifier: ^18.2.60
version: 18.2.60
'@types/react-dom':
specifier: ^18.2.19
version: 18.2.19
@ -604,7 +610,7 @@ packages:
react: '>=16'
dependencies:
'@types/mdx': 2.0.8
'@types/react': 18.2.58
'@types/react': 18.2.60
react: 18.2.0
dev: false
@ -758,8 +764,8 @@ packages:
universal-user-agent: 6.0.1
dev: false
/@octokit/graphql-schema@14.57.0:
resolution: {integrity: sha512-DvUIxJkfboiQEOme4DOpF4+EtOh1qbYffm/hbcFdqAQzWFPV19Nkul9t/XnyKRPFyy26Nwf8QesCOeYw8os5rQ==}
/@octokit/graphql-schema@14.58.0:
resolution: {integrity: sha512-89QSUV1Dgxzq90wqkv0Nmw7jHfFCAQ4K/fjp5ezvDEHqFFzMCn25TBQlm38WB8ams+hGxInRDbITCP0n7GTGlg==}
dependencies:
graphql: 16.8.1
graphql-tag: 2.12.6(graphql@16.8.1)
@ -969,7 +975,7 @@ packages:
/@types/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-YIQtIg4PKr7ZyqNPZObpxfHsHEmuB8dXCxd6qVcGuQVDK2bpsF7bYNnBJ4Nn7giuACZg+WewExgrtAJ3XnA4Xw==}
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
hoist-non-react-statics: 3.3.2
dev: false
@ -1021,6 +1027,12 @@ packages:
dependencies:
undici-types: 5.26.5
/@types/nodemailer@6.4.14:
resolution: {integrity: sha512-fUWthHO9k9DSdPCSPRqcu6TWhYyxTBg382vlNIttSe9M7XfsT06y0f24KHXtbnijPGGRIcVvdKHTNikOI6qiHA==}
dependencies:
'@types/node': 20.11.20
dev: true
/@types/novnc__novnc@1.3.4:
resolution: {integrity: sha512-JseVFIc9bm6IV2TkHi/oL4tr3anYeOw2BXX44HgqdRnRCaimz/nfZDWCyylK/OsJIfLzcchLLf2pxevYMNXFbQ==}
dev: true
@ -1035,17 +1047,17 @@ packages:
/@types/react-dom@18.2.19:
resolution: {integrity: sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==}
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
dev: true
/@types/react-is@18.2.4:
resolution: {integrity: sha512-wBc7HgmbCcrvw0fZjxbgz/xrrlZKzEqmABBMeSvpTvdm25u6KI6xdIi9pRE2G0C1Lw5ETFdcn4UbYZ4/rpqUYw==}
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
dev: true
/@types/react@18.2.58:
resolution: {integrity: sha512-TaGvMNhxvG2Q0K0aYxiKfNDS5m5ZsoIBBbtfUorxdH4NGSXIlYvZxLJI+9Dd3KjeB3780bciLyAb7ylO8pLhPw==}
/@types/react@18.2.60:
resolution: {integrity: sha512-dfiPj9+k20jJrLGOu9Nf6eqxm2EyJRrq2NvwOFsfbb7sFExZ9WELPs67UImHj3Ayxg8ruTtKtNnbjaF8olPq0A==}
dependencies:
'@types/prop-types': 15.7.11
'@types/scheduler': 0.16.4
@ -4516,6 +4528,11 @@ packages:
'@types/nlcst': 1.0.4
dev: false
/nodemailer@6.9.10:
resolution: {integrity: sha512-qtoKfGFhvIFW5kLfrkw2R6Nm6Ur4LNUMykyqu6n9BRKJuyQrqEGwdXXUAbwWEKt33dlWUGXb7rzmJP/p4+O+CA==}
engines: {node: '>=6.0.0'}
dev: false
/nopt@7.2.0:
resolution: {integrity: sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -4905,13 +4922,13 @@ packages:
react: 18.2.0
dev: false
/react-innertext@1.1.5(@types/react@18.2.58)(react@18.2.0):
/react-innertext@1.1.5(@types/react@18.2.60)(react@18.2.0):
resolution: {integrity: sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==}
peerDependencies:
'@types/react': '>=0.0.0 <=99'
react: '>=0.0.0 <=99'
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
react: 18.2.0
dev: false
@ -4948,7 +4965,7 @@ packages:
react-fast-compare: 3.2.2
dev: false
/react-textarea-autosize@8.5.3(@types/react@18.2.58)(react@18.2.0):
/react-textarea-autosize@8.5.3(@types/react@18.2.60)(react@18.2.0):
resolution: {integrity: sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==}
engines: {node: '>=10'}
peerDependencies:
@ -4957,7 +4974,7 @@ packages:
'@babel/runtime': 7.23.1
react: 18.2.0
use-composed-ref: 1.3.0(react@18.2.0)
use-latest: 1.2.1(@types/react@18.2.58)(react@18.2.0)
use-latest: 1.2.1(@types/react@18.2.60)(react@18.2.0)
transitivePeerDependencies:
- '@types/react'
dev: false
@ -5997,7 +6014,7 @@ packages:
react: 18.2.0
dev: false
/use-isomorphic-layout-effect@1.1.2(@types/react@18.2.58)(react@18.2.0):
/use-isomorphic-layout-effect@1.1.2(@types/react@18.2.60)(react@18.2.0):
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
'@types/react': '*'
@ -6006,11 +6023,11 @@ packages:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
react: 18.2.0
dev: false
/use-latest@1.2.1(@types/react@18.2.58)(react@18.2.0):
/use-latest@1.2.1(@types/react@18.2.60)(react@18.2.0):
resolution: {integrity: sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==}
peerDependencies:
'@types/react': '*'
@ -6019,9 +6036,9 @@ packages:
'@types/react':
optional: true
dependencies:
'@types/react': 18.2.58
'@types/react': 18.2.60
react: 18.2.0
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.58)(react@18.2.0)
use-isomorphic-layout-effect: 1.1.2(@types/react@18.2.60)(react@18.2.0)
dev: false
/use-sync-external-store@1.2.0(react@18.2.0):

View File

@ -1,7 +0,0 @@
-- CreateTable
CREATE TABLE "hits" (
"slug" VARCHAR(128) NOT NULL,
"hits" INTEGER NOT NULL DEFAULT 1,
CONSTRAINT "hits_pkey" PRIMARY KEY ("slug")
);

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@ -1,12 +1,11 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model hits {

View File

@ -32,7 +32,7 @@
- Vercel
- Stitches
- Prisma
- Supabase
- PlanetScale
- Giscus
- Fathom Analytics
- ...and more: https://jarv.is/uses/