diff --git a/node/email-contact-form/src/utils.js b/node/email-contact-form/src/utils.js index f1287b24..8e0f472b 100644 --- a/node/email-contact-form/src/utils.js +++ b/node/email-contact-form/src/utils.js @@ -37,7 +37,7 @@ export function getStaticFile(fileName) { /** * Build a message from the form data. * @param {import("node:querystring").ParsedUrlQuery} form - * @returns {string} + * @returns {string} */ export function templateFormMessage(form) { return `You've received a new message.\n @@ -50,6 +50,7 @@ ${Object.entries(form) /** * @param {string} baseUrl * @param {string} codeParam + * @returns {string} */ export function urlWithCodeParam(baseUrl, codeParam) { const url = new URL(baseUrl); @@ -57,6 +58,11 @@ export function urlWithCodeParam(baseUrl, codeParam) { return url.toString(); } +/** + * Send an email using the SMTP credentials in the environment + * @param {any} options + * @returns {Promise} + */ export async function sendEmail(options) { const transport = nodemailer.createTransport({ // @ts-ignore diff --git a/php/email-contact-form/.gitignore b/php/email-contact-form/.gitignore new file mode 100644 index 00000000..65a21c66 --- /dev/null +++ b/php/email-contact-form/.gitignore @@ -0,0 +1,470 @@ +##### Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +##### Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +##### MacOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +##### Backup +*.bak +*.gho +*.ori +*.orig +*.tmp + +##### GPG +secring.* + +##### Dropbox +# Dropbox settings and caches +.dropbox +.dropbox.attr +.dropbox.cache + +##### SynopsysVCS +# Waveform formats +*.vcd +*.vpd +*.evcd +*.fsdb + +# Default name of the simulation executable. A different name can be +# specified with this switch (the associated daidir database name is +# also taken from here): -o / +simv + +# Generated for Verilog and VHDL top configs +simv.daidir/ +simv.db.dir/ + +# Infrastructure necessary to co-simulate SystemC models with +# Verilog/VHDL models. An alternate directory may be specified with this +# switch: -Mdir= +csrc/ + +# Log file - the following switch allows to specify the file that will be +# used to write all messages from simulation: -l +*.log + +# Coverage results (generated with urg) and database location. The +# following switch can also be used: urg -dir .vdb +simv.vdb/ +urgReport/ + +# DVE and UCLI related files. +DVEfiles/ +ucli.key + +# When the design is elaborated for DirectC, the following file is created +# with declarations for C/C++ functions. +vc_hdrs.h + +##### SVN +.svn/ + +##### Mercurial +.hg/ +.hgignore +.hgsigs +.hgsub +.hgsubstate +.hgtags + +##### Bazaar +.bzr/ +.bzrignore + +##### CVS +/CVS/* +**/CVS/* +.cvsignore +*/.cvsignore + +##### TortoiseGit +# Project-level settings +/.tgitconfig + +##### PuTTY +# Private key +*.ppk + +##### Vim +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +##### Emacs +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# network security +/network-security.data + +##### SublimeText +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json +sftp-config-alt*.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + +##### Notepad++ +# Notepad++ backups # +*.bak + +##### TextMate +*.tmproj +*.tmproject +tmtags + +##### VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +##### NetBeans +**/nbproject/private/ +**/nbproject/Makefile-*.mk +**/nbproject/Package-*.bash +build/ +nbbuild/ +dist/ +nbdist/ +.nb-gradle/ + +##### JetBrains +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +##### Eclipse +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders + +# External tool builders +.externalToolBuilders/ + +# Locally stored "Eclipse launch configurations" +*.launch + +# PyDev specific (Python IDE for Eclipse) +*.pydevproject + +# CDT-specific (C/C++ Development Tooling) +.cproject + +# CDT- autotools +.autotools + +# Java annotation processor (APT) +.factorypath + +# PDT-specific (PHP Development Tools) +.buildpath + +# sbteclipse plugin +.target + +# Tern plugin +.tern-project + +# TeXlipse plugin +.texlipse + +# STS (Spring Tool Suite) +.springBeans + +# Code Recommenders +.recommenders/ + +# Annotation Processing +.apt_generated/ +.apt_generated_test/ + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# Uncomment this line if you wish to ignore the project description file. +# Typically, this file would be tracked if it contains build/dependency configurations: +#.project + +##### Dreamweaver +# DW Dreamweaver added files +_notes +_compareTemp +configs/ +dwsync.xml +dw_php_codehinting.config +*.mno + +##### CodeKit +# General CodeKit files to ignore +config.codekit +config.codekit3 +/min + +##### Gradle +.gradle +**/build/ +!src/**/build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +##### Composer +composer.phar +/vendor/ + +##### PHP CodeSniffer +# gitignore for the PHP Codesniffer framework +# website: https://github.com/squizlabs/PHP_CodeSniffer +# +# Recommended template: PHP.gitignore + +/wpcs/* + +##### SASS +.sass-cache/ +*.css.map +*.sass.map +*.scss.map \ No newline at end of file diff --git a/php/email-contact-form/README.md b/php/email-contact-form/README.md new file mode 100644 index 00000000..6be58c2d --- /dev/null +++ b/php/email-contact-form/README.md @@ -0,0 +1,101 @@ +# 📬 PHP 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 | PHP (8.0) | +| Entrypoint | `src/index.php` | +| Build Commands | `composer install` | +| 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/php/email-contact-form/composer.json b/php/email-contact-form/composer.json new file mode 100644 index 00000000..edd87951 --- /dev/null +++ b/php/email-contact-form/composer.json @@ -0,0 +1,8 @@ +{ + "name": "templates/email-contact-form", + "type": "library", + "require": { + "php": ">=8.0.0", + "phpmailer/phpmailer": "^6.8" + } +} diff --git a/php/email-contact-form/src/cors.php b/php/email-contact-form/src/cors.php new file mode 100644 index 00000000..7d9629a5 --- /dev/null +++ b/php/email-contact-form/src/cors.php @@ -0,0 +1,37 @@ +headers['origin'] + ) { + return true; + } + $allowedOriginsArray = explode(',', getenv('ALLOWED_ORIGINS')); + return in_array($req->headers['origin'], $allowedOriginsArray); +} + +/** + * Returns the CORS headers for the request + * @param mixed $req + * @return array + */ +function get_cors_headers(mixed $req): array +{ + if (!$req->headers['origin']) { + return []; + } + return [ + 'Access-Control-Allow-Origin' => + !getenv('ALLOWED_ORIGINS') || getenv('ALLOWED_ORIGINS') === '*' + ? '*' + : $req->headers['origin'], + ]; +} diff --git a/php/email-contact-form/src/index.php b/php/email-contact-form/src/index.php new file mode 100644 index 00000000..ff9884e0 --- /dev/null +++ b/php/email-contact-form/src/index.php @@ -0,0 +1,85 @@ + 'invalid-request', + 'MISSING_FORM_FIELDS' => 'missing-form-fields', + 'SERVER_ERROR' => 'server-error', +]; + +return function ($context) { + global $ERROR_CODE; + + throw_if_missing($_ENV, [ + 'SUBMIT_EMAIL', + 'SMTP_HOST', + 'SMTP_USERNAME', + 'SMTP_PASSWORD', + ]); + + if (!getenv('ALLOWED_ORIGINS') || getenv('ALLOWED_ORIGINS') === '*') { + $context->log( + 'WARNING: Allowing requests from any origin - this is a security risk!' + ); + } + + if ($context->req->method === 'GET' && $context->req->path === '/') { + return $context->send(get_static_file('index.html'), 200, [ + 'content-type' => 'text/html; charset=utf-8', + ]); + } + + if ($context->req->headers['content-type'] !== 'application/x-www-form-urlencoded') { + $context->error('Incorrect content type.'); + return $context->redirect( + $context->req->headers['referer'] + "?code=" + $ERROR_CODE['INVALID_REQUEST'] + ); + } + + if (!is_origin_permitted($context->req)) { + $context->error('Origin not permitted.'); + return $context->redirect( + $context->req->headers['referer'] + "?code=" + $ERROR_CODE['INVALID_REQUEST'] + ); + } + + $form = []; + parse_str($context->req->body, $form); + + try { + throw_if_missing($form, ['email']); + } catch (Exception $err) { + return $context->redirect( + $context->req->headers['referer'] + "?code=" + $err->getMessage() + ); + } + + try { + send_email([ + 'to' => $_ENV['SUBMIT_EMAIL'], + 'from' => $form['email'], + 'subject' => 'New form submission: ' . $context->req->headers['referer'], + 'text' => template_form_message($form), + ]); + } catch (Exception $err) { + $context->error($err->getMessage()); + return $context->redirect( + $context->req->headers['referer'] + "?code=" + $ERROR_CODE['SERVER_ERROR'] + ); + } + + if (!isset($form['_next']) || empty($form['_next'])) { + return $context->send(get_static_file('success.html'), 200, [ + 'content-type' => 'text/html; charset=utf-8', + ]); + } + + return $context->redirect( + $context->req->headers['referer'] + $form['_next'], + 301, + get_cors_headers($context->req) + ); +}; diff --git a/php/email-contact-form/src/utils.php b/php/email-contact-form/src/utils.php new file mode 100644 index 00000000..5e4f2ca9 --- /dev/null +++ b/php/email-contact-form/src/utils.php @@ -0,0 +1,77 @@ + 0) { + throw new \Exception('Missing required fields: ' . implode(', ', $missing)); + } +} + +/** + * Sends an email using the SMTP credentials in the environment + * @param array $options + * @throws \PHPMailer\PHPMailer\Exception + */ +function send_email(array $options): void +{ + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $_ENV['SMTP_HOST']; + $mail->Port = $_ENV['SMTP_PORT'] ?? 587; + $mail->SMTPAuth = true; + $mail->Username = $_ENV['SMTP_USERNAME']; + $mail->Password = $_ENV['SMTP_PASSWORD']; + + $mail->setFrom($options['from']); + $mail->addAddress($options['to']); + $mail->Subject = $options['subject']; + $mail->Body = $options['body']; + + $mail->send(); +} + +/** + * Builds a string message body from a form submission + * @param array $form + * @return string + */ +function template_form_message(array $form) +{ + return "You've received a new message.\n" . + implode("\n", array_map(function ($key, $value) { + return "$key: $value"; + }, array_filter($form, function ($key) { + return $key !== '_next'; + }, ARRAY_FILTER_USE_KEY), array_filter($form, function ($key) { + return $key !== '_next'; + }, ARRAY_FILTER_USE_KEY))); +} diff --git a/php/email-contact-form/static/index.html b/php/email-contact-form/static/index.html new file mode 100644 index 00000000..b324a86c --- /dev/null +++ b/php/email-contact-form/static/index.html @@ -0,0 +1,44 @@ + + + + + + + Email Contact Form + + + + +
+
+
+
+

Contact

+ +
+

+ Fill the form below to send us a message. +

+
+ + + +
+
+
+
+ + diff --git a/php/email-contact-form/static/success.html b/php/email-contact-form/static/success.html new file mode 100644 index 00000000..94ba2a8d --- /dev/null +++ b/php/email-contact-form/static/success.html @@ -0,0 +1,31 @@ + + + + + + + Email Contact Form + + + + +
+
+
+
+

Success

+ +
+

+ Your message has been sent! +

+
+
+
+ + diff --git a/php/sync-with-algolia/composer.json b/php/sync-with-algolia/composer.json index f61d92e5..4e011301 100644 --- a/php/sync-with-algolia/composer.json +++ b/php/sync-with-algolia/composer.json @@ -1,5 +1,5 @@ { - "name": "templates/starter", + "name": "templates/sync-with-algolia", "type": "library", "require": { "php": ">=8.0.0", diff --git a/python/email_contact_form/.gitignore b/python/email_contact_form/.gitignore new file mode 100644 index 00000000..68bc17f9 --- /dev/null +++ b/python/email_contact_form/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/python/email_contact_form/README.md b/python/email_contact_form/README.md new file mode 100644 index 00000000..daa0ba2c --- /dev/null +++ b/python/email_contact_form/README.md @@ -0,0 +1,101 @@ +# 📬 Python 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 | Python (3.9) | +| Entrypoint | `src/main.py` | +| Build Commands | `pip install -r requirements.txt` | +| 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/python/email_contact_form/src/cors.py b/python/email_contact_form/src/cors.py new file mode 100644 index 00000000..0df612fd --- /dev/null +++ b/python/email_contact_form/src/cors.py @@ -0,0 +1,38 @@ +import os + + +def is_origin_permitted(req): + """ + Returns true if the origin is allowed to make requests to this endpoint + + Parameters: + req: Request object + + Returns: + (bool): True if the origin is allowed, False otherwise + """ + if os.getenv("ALLOWED_ORIGINS", "*") == "*": + return True + + allowed_origins_list = os.getenv("ALLOWED_ORIGINS").split(",") + return req.headers.get("Origin") in allowed_origins_list + + +def get_cors_headers(req): + """ + Returns the CORS headers for the request + + Parameters: + req: Request object + + Returns: + (dict): CORS headers + """ + if "origin" not in req.headers: + return {} + + return { + "Access-Control-Allow-Origin": "*" + if os.getenv("ALLOWED_ORIGINS", "*") == "*" + else req.headers["origin"], + } diff --git a/python/email_contact_form/src/main.py b/python/email_contact_form/src/main.py new file mode 100644 index 00000000..5a7e1737 --- /dev/null +++ b/python/email_contact_form/src/main.py @@ -0,0 +1,91 @@ +import os + +from utils import get_static_file, throw_if_missing, send_email, template_form_message +from cors import is_origin_permitted, get_cors_headers +from urllib.parse import parse_qs, urljoin + + +class ErrorCode: + INVALID_REQUEST = "invalid-request" + MISSING_FORM_FIELDS = "missing-form-fields" + SERVER_ERROR = "server-error" + + +def main(context): + throw_if_missing( + os.environ, + [ + "SUBMIT_EMAIL", + "SMTP_HOST", + "SMTP_USERNAME", + "SMTP_PASSWORD", + ], + ) + + if os.getenv("ALLOWED_ORIGINS", "*") == "*": + context.log( + "WARNING: Allowing requests from any origin - this is a security risk!" + ) + + if context.req.method == "GET" and context.req.path == "/": + return context.res.send( + get_static_file("index.html"), + 200, + { + "Content-Type": "text/html; charset=utf-8", + }, + ) + + if context.req.headers["content-type"] == "application/x-www-form-urlencoded": + context.error("Incorrect content type") + return context.res.redirect( + f"{context.req.headers['referer']}?code={ErrorCode.INVALID_REQUEST}" + ) + + if not is_origin_permitted(context.req): + context.error("Origin not permitted") + return context.res.redirect( + f"{context.req.headers['referer']}?code={ErrorCode.INVALID_REQUEST}" + ) + + form = parse_qs(context.req.body) + form = {key: value[0] for key, value in form.items()} + + try: + throw_if_missing(form, ["email"]) + except ValueError as err: + return context.res.redirect( + f"{context.req.headers['referer']}?code={ErrorCode.MISSING_FORM_FIELDS}", + 301, + get_cors_headers(context.req), + ) + + try: + send_email( + { + "from": os.getenv("SUBMIT_EMAIL"), + "to": os.getenv("SUBMIT_EMAIL"), + "subject": "New Contact Form Submission", + "text": template_form_message(form), + } + ) + except Exception as err: + context.error(err) + return context.res.redirect( + f"{context.req.headers['referer']}?code={ErrorCode.SERVER_ERROR}", + 301, + get_cors_headers(context.req), + ) + + if not form["_next"]: + return context.res.send( + get_static_file("success.html"), + 200, + "content-type: text/html; charset=utf-8", + ) + + return context.res.redirect( + urljoin(context.req.headers["referer"], form["_next"][0]), + 301, + get_cors_headers(context.req), + ) diff --git a/python/email_contact_form/src/utils.py b/python/email_contact_form/src/utils.py new file mode 100644 index 00000000..d4418ef2 --- /dev/null +++ b/python/email_contact_form/src/utils.py @@ -0,0 +1,76 @@ +import os + +from smtplib import SMTP +from email import message + +__dirname = os.path.dirname(os.path.abspath(__file__)) +static_folder = os.path.join(__dirname, "../static") + + +def get_static_file(file_name: str) -> str: + """ + Returns the contents of a file in the static folder + + Parameters: + file_name (str): Name of the file to read + + Returns: + (str): Contents of static/{file_name} + """ + file_path = os.path.join(static_folder, file_name) + with open(file_path, "r") as file: + return file.read() + + +def throw_if_missing(obj: object, keys: list[str]) -> None: + """ + Throws an error if any of the keys are missing from the object + + Parameters: + obj (object): Object to check + keys (list[str]): List of keys to check + + Raises: + ValueError: If any keys are missing + """ + missing = [key for key in keys if key not in obj or not obj[key]] + if missing: + raise ValueError(f"Missing required fields: {', '.join(missing)}") + + +def send_email(options): + """ + Sends an email using the SMTP credentials in the environment + + Parameters: + options (dict): Email options + """ + transport = SMTP( + host=os.environ["SMTP_HOST"], + port=os.environ.get("SMTP_PORT", 587), + username=os.environ["SMTP_USERNAME"], + password=os.environ["SMTP_PASSWORD"], + ) + + message = message.EmailMessage() + message.set_content(options["text"]) + message["Subject"] = options["subject"] + message["From"] = options["from"] + message["To"] = options["to"] + + transport.send_message(message) + + +def template_form_message(form): + """ + Builds a string message body from a form submission + + Parameters: + form (dict): Form submission + + Returns: + (str): Message body + """ + return "You've received a new message:\n" + "\n".join( + [f"{key}: {value}" for key, value in form.items() if key != "_next"] + ) diff --git a/python/email_contact_form/static/index.html b/python/email_contact_form/static/index.html new file mode 100644 index 00000000..b324a86c --- /dev/null +++ b/python/email_contact_form/static/index.html @@ -0,0 +1,44 @@ + + + + + + + Email Contact Form + + + + +
+
+
+
+

Contact

+ +
+

+ Fill the form below to send us a message. +

+
+ + + +
+
+
+
+ + diff --git a/python/email_contact_form/static/success.html b/python/email_contact_form/static/success.html new file mode 100644 index 00000000..94ba2a8d --- /dev/null +++ b/python/email_contact_form/static/success.html @@ -0,0 +1,31 @@ + + + + + + + Email Contact Form + + + + +
+
+
+
+

Success

+ +
+

+ Your message has been sent! +

+
+
+
+ +