Contact
+
+ + Fill the form below to send us a message. +
+ +diff --git a/node/email-contact-form/.gitignore b/node/email-contact-form/.gitignore new file mode 100644 index 00000000..6a7d6d8e --- /dev/null +++ b/node/email-contact-form/.gitignore @@ -0,0 +1,130 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/node/email-contact-form/.prettierrc.json b/node/email-contact-form/.prettierrc.json new file mode 100644 index 00000000..0a725205 --- /dev/null +++ b/node/email-contact-form/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/node/email-contact-form/README.md b/node/email-contact-form/README.md new file mode 100644 index 00000000..88d654ca --- /dev/null +++ b/node/email-contact-form/README.md @@ -0,0 +1,102 @@ +# 📬 Node.js Email Contact Form Function + +Sends an email with the contents of a HTML form. + +## 🧰 Usage + +### `GET` + +HTML form for interacting with the function. + +### `POST` + +Submit form data to send an email + +**Parameters** + +| Name | Description | Location | Type | Sample Value | +| ------ | --------------------------------- | ---------- | ------ | -------------------------------- | +| \_next | URL for redirect after submission | Form Param | String | `https://mywebapp.org/success` | +| \* | Any form values to send in email | Form Param | String | `Hey, I'd like to get in touch!` | + +**Response** + +Sample `200` Response: + +```text +Location: https://mywebapp.org/success +``` + +Sample `400` Response: + +```text +Location: https://mywebapp.org/referer?error=Invalid+email+address +``` + +## ⚙️ Configuration + +| Setting | Value | +| ----------------- | --------------- | +| Runtime | Node (18.0) | +| Entrypoint | `src/main.js` | +| Build Commands | `npm install` | +| | `npm run setup` | +| Permissions | `any` | +| Timeout (Seconds) | 15 | + +## 🔒 Environment Variables + +### SMTP_HOST + +The address of your SMTP server. Many STMP providers will provide this information in their documentation. Some popular providers include: Mailgun, SendGrid, and Gmail. + +| Question | Answer | +| ------------ | ------------------ | +| Required | Yes | +| Sample Value | `smtp.mailgun.org` | + +### SMTP_PORT + +The port of your STMP server. Commnly used ports include `25`, `465`, and `587`. + +| Question | Answer | +| ------------ | ------ | +| Required | Yes | +| Sample Value | `25` | + +### SMTP_USERNAME + +The username for your SMTP server. This is commonly your email address. + +| Question | Answer | +| ------------ | ----------------------- | +| Required | Yes | +| Sample Value | `no-reply@mywebapp.org` | + +### SMTP_PASSWORD + +The password for your SMTP server. + +| Question | Answer | +| ------------ | --------------------- | +| Required | Yes | +| Sample Value | `5up3r5tr0ngP4ssw0rd` | + +### SUBMIT_EMAIL + +The email address to send form submissions to. + +| Question | Answer | +| ------------ | ----------------- | +| Required | Yes | +| Sample Value | `me@mywebapp.org` | + +### ALLOWED_ORIGINS + +An optional comma-separated list of allowed origins for CORS (defaults to `*`). This is an important security measure to prevent malicious users from abusing your function. + +| Question | Answer | +| ------------- | ------------------------------------------------------------------- | +| Required | No | +| Sample Value | `https://mywebapp.org,https://mywebapp.com` | +| Documentation | [MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) | diff --git a/node/email-contact-form/env.d.ts b/node/email-contact-form/env.d.ts new file mode 100644 index 00000000..8da210de --- /dev/null +++ b/node/email-contact-form/env.d.ts @@ -0,0 +1,14 @@ +declare global { + namespace NodeJS { + interface ProcessEnv { + STMP_HOST: string; + STMP_PORT?: string; + STMP_USERNAME: string; + STMP_PASSWORD: string; + SUBMIT_EMAIL: string; + ALLOWED_ORIGINS?: string; + } + } +} + +export {}; diff --git a/node/email-contact-form/package-lock.json b/node/email-contact-form/package-lock.json new file mode 100644 index 00000000..1fbc6811 --- /dev/null +++ b/node/email-contact-form/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "email-contact-form", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "email-contact-form", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "nodemailer": "^6.9.3" + }, + "devDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/nodemailer": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.3.tgz", + "integrity": "sha512-fy9v3NgTzBngrMFkDsKEj0r02U7jm6XfC3b52eoNV+GCrGj+s8pt5OqhiJdWKuw51zCTdiNR/IUD1z33LIIGpg==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.0.tgz", + "integrity": "sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/node/email-contact-form/package.json b/node/email-contact-form/package.json new file mode 100644 index 00000000..420dd797 --- /dev/null +++ b/node/email-contact-form/package.json @@ -0,0 +1,16 @@ +{ + "name": "email-contact-form", + "version": "1.0.0", + "description": "", + "main": "src/main.js", + "type": "module", + "scripts": { + "format": "prettier --write ." + }, + "dependencies": { + "nodemailer": "^6.9.3" + }, + "devDependencies": { + "prettier": "^3.0.0" + } +} diff --git a/node/email-contact-form/src/cors.js b/node/email-contact-form/src/cors.js new file mode 100644 index 00000000..29d597e6 --- /dev/null +++ b/node/email-contact-form/src/cors.js @@ -0,0 +1,15 @@ +export function isOriginPermitted(req) { + if (!process.env.ALLOWED_ORIGINS || process.env.ALLOWED_ORIGINS === '*') + return true; + const allowedOriginsArray = process.env.ALLOWED_ORIGINS.split(','); + return allowedOriginsArray.includes(req.headers['origin']); +} + +export function getCorsHeaders(req) { + return { + 'Access-Control-Allow-Origin': + !process.env.ALLOWED_ORIGINS || process.env.ALLOWED_ORIGINS === '*' + ? '*' + : req.headers['origin'], + }; +} diff --git a/node/email-contact-form/src/main.js b/node/email-contact-form/src/main.js new file mode 100644 index 00000000..4a560f2c --- /dev/null +++ b/node/email-contact-form/src/main.js @@ -0,0 +1,91 @@ +import querystring from 'node:querystring'; +import { getCorsHeaders, isOriginPermitted } from './cors.js'; +import { + getStaticFile, + throwIfMissing, + urlWithCodeParam, + templateFormMessage, + sendEmail, +} from './utils.js'; + +const ErrorCode = { + INVALID_REQUEST: 'invalid-request', + MISSING_FORM_FIELDS: 'missing-form-fields', + SERVER_ERROR: 'server-error', +}; + +export default async ({ req, res, log, error }) => { + throwIfMissing(process.env, [ + 'SUBMIT_EMAIL', + 'SMTP_HOST', + 'SMTP_USERNAME', + 'SMTP_PASSWORD', + ]); + + if (!process.env.ALLOWED_ORIGINS || process.env.ALLOWED_ORIGINS === '*') { + log( + 'WARNING: Allowing requests from any origin - this is a security risk!' + ); + } + + if (req.method === 'GET' && req.path === '/') { + return res.send(getStaticFile('index.html'), 200, { + 'Content-Type': 'text/html; charset=utf-8', + }); + } + + throwIfMissing(req.headers, ['referer', 'origin']); + + if (req.headers['content-type'] !== 'application/x-www-form-urlencoded') { + error('Incorrect content type.'); + return res.redirect( + urlWithCodeParam(req.headers['referer'], ErrorCode.INVALID_REQUEST) + ); + } + + if (!isOriginPermitted(req.headers['origin'])) { + error('Origin not permitted.'); + return res.redirect( + urlWithCodeParam(req.headers['referer'], ErrorCode.INVALID_REQUEST) + ); + } + + const form = querystring.parse(req.body); + try { + throwIfMissing(form, ['email']); + } catch (err) { + return res.redirect( + urlWithCodeParam(req.headers['referer'], err.message), + 301, + getCorsHeaders(req.headers['origin']) + ); + } + + try { + sendEmail({ + to: process.env.SUBMIT_EMAIL, + from: /** @type {string} */ (form['email']), + subject: `New form submission: ${origin}`, + text: templateFormMessage(form), + }); + } catch (err) { + error(err.message); + return res.redirect( + urlWithCodeParam(req.headers['referer'], ErrorCode.SERVER_ERROR), + 301, + getCorsHeaders(req.headers['origin']) + ); + } + + if (typeof form._next !== 'string' || !form._next) { + return res.send(getStaticFile('success.html'), 200, { + 'Content-Type': 'text/html; charset=utf-8', + }); + } + + return res.redirect( + new URL(form._next, req.headers['origin']).toString(), + 301, + getCorsHeaders(req.headers['origin']) + ); +}; diff --git a/node/email-contact-form/src/utils.js b/node/email-contact-form/src/utils.js new file mode 100644 index 00000000..5cbdd4db --- /dev/null +++ b/node/email-contact-form/src/utils.js @@ -0,0 +1,75 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; +import nodemailer from 'nodemailer'; + +/** + * Throws an error if any of the keys are missing from the object + * @param {*} obj + * @param {string[]} keys + * @throws {Error} + */ +export function throwIfMissing(obj, keys) { + const missing = []; + for (let key of keys) { + if (!(key in obj) || !obj[key]) { + missing.push(key); + } + } + if (missing.length > 0) { + throw new Error(`Missing required fields: ${missing.join(', ')}`); + } +} + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const staticFolder = path.join(__dirname, '../static'); + +/** + * Returns the contents of a file in the static folder + * @param {string} fileName + * @returns {string} Contents of static/{fileName} + */ +export function getStaticFile(fileName) { + return fs.readFileSync(path.join(staticFolder, fileName)).toString(); +} + +/** + * Build a message from the form data. + * @param {import("node:querystring").ParsedUrlQuery} form + * @returns {string} + */ +export function templateFormMessage(form) { + return `You've received a new message.\n + ${Object.entries(form) + .filter(([key]) => key !== '_next') + .map(([key, value]) => `${key}: ${value}`) + .join('\n')}`; +} + +/** + * @param {string} baseUrl + * @param {string} codeParam + */ +export function urlWithCodeParam(baseUrl, codeParam) { + const url = new URL(baseUrl); + url.searchParams.set('code', codeParam); + return url.toString(); +} + +/** + * @param {import('nodemailer').SendMailOptions} options + */ +export async function sendEmail(options) { + const transport = nodemailer.createTransport({ + // @ts-ignore + // Not sure what's going on here. + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT || 587, + auth: { + user: process.env.SMTP_USERNAME, + pass: process.env.SMTP_PASSWORD, + }, + }); + await transport.sendMail(options); +} diff --git a/node/email-contact-form/static/index.html b/node/email-contact-form/static/index.html new file mode 100644 index 00000000..29670f76 --- /dev/null +++ b/node/email-contact-form/static/index.html @@ -0,0 +1,45 @@ + + +
+ + + +
+ + Fill the form below to send us a message. +
+ +
+ + Your message has been sent! +
+