Add a contact form to the resume (#1)
All checks were successful
release / Publish to Cloudflare Pages (push) Successful in 1m6s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2025-02-03 18:10:09 +10:30
parent fd4f30e33f
commit e0c12292cd
17 changed files with 3869 additions and 9 deletions

View File

@@ -2,7 +2,6 @@ name: release
on: on:
push: push:
branches: [main]
jobs: jobs:
publish: publish:
@@ -17,6 +16,8 @@ jobs:
with: with:
lfs: true lfs: true
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pico - name: Install pico
run: npm ci run: npm ci
@@ -25,12 +26,18 @@ jobs:
run: ./build.sh run: ./build.sh
- name: Publish to Cloudflare Pages - name: Publish to Cloudflare Pages
uses: cloudflare/pages-action@v1 uses: cloudflare/wrangler-action@v3
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: resume command: pages deploy build --project-name=resume
directory: build
- name: Publish Email Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: contact-email-worker
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/node_modules node_modules
.DS_Store .DS_Store
.vscode .vscode
build build
.wrangler

View File

@@ -2,6 +2,8 @@
A dead simple website showcasing my career and interests! A dead simple website showcasing my career and interests!
Also includes a contact form, which sends an email with relevant details from whoever is trying to make contact, using CloudFlare Workers and Email Routing.
## Build ## Build
Ensure npm is installed. Ensure npm is installed.

View File

@@ -1,5 +1,6 @@
mkdir -p build/@picocss/pico/css/ mkdir -p build/@picocss/pico/css/
cp *.png *.xml *.svg *.css *.webmanifest *.ico robots.txt _headers build cp -r *.png *.xml *.svg *.css *.webmanifest *.ico robots.txt _headers functions contact build
# https://github.com/cloudflare/workers-sdk/issues/3615 # https://github.com/cloudflare/workers-sdk/issues/3615
sed 's/node_modules\///' index.html > build/index.html sed 's/node_modules\///' index.html > build/index.html
sed 's/node_modules\///' contact/index.html > build/contact/index.html
cp node_modules/@picocss/pico/css/pico.min.css build/@picocss/pico/css/ cp node_modules/@picocss/pico/css/pico.min.css build/@picocss/pico/css/

2446
contact-email-worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"name": "royal-leaf-c03c",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.6.4",
"@cloudflare/workers-types": "^4.20250129.0",
"typescript": "^5.5.2",
"wrangler": "^3.107.2"
},
"dependencies": {
"mimetext": "^3.0.27"
}
}

View File

@@ -0,0 +1,56 @@
import { EmailMessage } from "cloudflare:email";
import { WorkerEntrypoint } from "cloudflare:workers";
import { createMimeMessage } from "mimetext";
const formatEmptyString = (s: string) => s ?? "Not Specified";
interface EmailDetails {
fullName: string;
organisation: string;
email: string;
mobile: string;
message: string;
}
export default class SendEmailWorker extends WorkerEntrypoint<Env> {
async fetch() {
return new Response("Unimplemented");
}
async sendEmail({
fullName,
organisation,
email,
mobile,
message,
}: EmailDetails) {
const msg = createMimeMessage();
msg.setSender({
name: "Michael Pivato Contact Form",
addr: "contact@michaelpivato.dev",
});
msg.setRecipient("contact@michaelpivato.dev");
msg.setSubject(`Message from ${fullName ?? email}`);
msg.addMessage({
contentType: "text/plain",
data: `You've received a new message from ${fullName ?? email}.
Full Name: ${formatEmptyString(fullName)}
Organisation: ${formatEmptyString(organisation)}
Email: ${formatEmptyString(email)}
Mobile: ${formatEmptyString(mobile)}
Message:
${message}`,
});
try {
const cfMessage = new EmailMessage(
"contact@michaelpivato.dev",
"contact@michaelpivato.dev",
msg.asRaw()
);
await this.env.SEB.send(cfMessage);
} catch (e) {
throw e;
}
}
}

View File

@@ -0,0 +1,46 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2021",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": ["es2021"],
/* Specify what JSX code is generated. */
"jsx": "react-jsx",
/* Specify what module code is generated. */
"module": "es2022",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "Bundler",
/* Specify type package names to be included without being referenced in a source file. */
"types": [
"@cloudflare/workers-types/2023-07-01"
],
/* Enable importing .json files */
"resolveJsonModule": true,
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"allowJs": true,
/* Enable error reporting in type-checked JavaScript files. */
"checkJs": false,
/* Disable emitting files from a compilation. */
"noEmit": true,
/* Ensure that each file can be safely transpiled without relying on other imports. */
"isolatedModules": true,
/* Allow 'import x from y' when a module doesn't have a default export. */
"allowSyntheticDefaultImports": true,
/* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true,
/* Enable all strict type-checking options. */
"strict": true,
/* Skip type checking all .d.ts files. */
"skipLibCheck": true
},
"exclude": ["test"],
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
}

View File

@@ -0,0 +1,5 @@
// Generated by Wrangler by running `wrangler types`
interface Env {
SEB: SendEmail;
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "contact-email",
"main": "src/index.ts",
"compatibility_date": "2025-01-29",
"observability": {
"enabled": true
},
"send_email": [
{ "name": "SEB", "destination_address": "contact@michaelpivato.dev" }
],
"workers_dev": false,
"compatibility_flags": ["nodejs_compat"]
}

96
contact/index.html Normal file
View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Michael Pivato | Contact</title>
<link
rel="stylesheet"
href="../node_modules/@picocss/pico/css/pico.min.css"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="../apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="../favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="../favicon-16x16.png"
/>
<link rel="manifest" href="../site.webmanifest" />
<link rel="mask-icon" href="../safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="#13171f"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="#2a3140"
/>
<meta name="description" content="Michael Pivato's Resume: Contact Form" />
</head>
<body>
<main class="container">
<header>
<hgroup>
<h1>Michael Pivato</h1>
<p>Send Michael a message</p>
<a href="../">Back to resume</a>
</hgroup>
</header>
<form data-static-form-name="contact">
<fieldset>
<label>
Name
<input name="name" placeholder="Name" autocomplete="name" />
</label>
<label
>Organisation
<input
name="org"
placeholder="Organisation"
autocomplete="organization"
/>
</label>
<label>
Email
<input
type="email"
name="email"
placeholder="Email"
autocomplete="email"
/>
</label>
<label>
Mobile
<input
type="tel"
name="mobile"
placeholder="Mobile"
autocomplete="mobile"
/>
</label>
<label>
Message
<textarea name="message" placeholder="Mesage..."></textarea>
</label>
</fieldset>
<input type="submit" value="Send Message" />
</form>
<footer class="container">
<small>Michael Pivato • 2025</small>
</footer>
</main>
</body>
</html>

47
functions/contact.ts Normal file
View File

@@ -0,0 +1,47 @@
import staticFormsPlugin from "@cloudflare/pages-plugin-static-forms";
interface EmailDetails {
fullName: string;
organisation: string;
email: string;
mobile: string;
message: string;
}
interface SendEmailWorker {
sendEmail(rawMessage: EmailDetails): Promise<Response>;
}
interface Env {
SERVICE: SendEmailWorker;
}
export const onRequest: PagesFunction<Env> = (context) => {
// Wrap static forms plugin so we can extract the env to use email routing
return staticFormsPlugin({
respondWith: async ({ formData }) => {
const fullName = formData.get("name");
const organisation = formData.get("org");
const email = formData.get("email");
const mobile = formData.get("mobile");
const message = formData.get("message");
// Must have some kind of identifiable information for me to actually care about them.
if ((fullName || email) && message) {
try {
await context.env.SERVICE.sendEmail({
fullName,
organisation,
email,
mobile,
message,
});
} catch (e) {
return new Response(e);
}
}
return Response.redirect("https://michaelpivato.dev");
},
})(context);
};

13
functions/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"lib": [
"esnext"
],
"types": [
"@cloudflare/workers-types"
]
}
}

View File

@@ -63,6 +63,7 @@
<hgroup> <hgroup>
<h1>Michael Pivato</h1> <h1>Michael Pivato</h1>
<p>Career summary and interests</p> <p>Career summary and interests</p>
<a href="contact">Contact</a>
</hgroup> </hgroup>
</header> </header>
<p> <p>

1092
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,17 @@
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
"type": "module",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@picocss/pico": "^2.0.0" "@cloudflare/pages-plugin-static-forms": "^1.0.3",
"@picocss/pico": "^2.0.0",
"mimetext": "^3.0.27"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250129.0",
"typescript": "^5.7.3",
"wrangler": "^3.107.2"
} }
} }

7
wrangler.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "resume",
"compatibility_date": "2025-01-29",
"services": [{ "binding": "SERVICE", "service": "contact-email" }],
"pages_build_output_dir": "build"
}