{"id":22337285,"url":"https://github.com/mbland/elistman","last_synced_at":"2025-07-29T22:32:49.973Z","repository":{"id":148995250,"uuid":"615061127","full_name":"mbland/elistman","owner":"mbland","description":"Mailing list system providing address validation and unsubscribe URIs","archived":false,"fork":false,"pushed_at":"2024-08-17T16:06:29.000Z","size":936,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"main","last_synced_at":"2024-08-17T17:24:48.918Z","etag":null,"topics":["api-gateway","aws-lambda","aws-sam","aws-ses","dynamodb","lambda","serverless","simple-email-service"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mpl-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mbland.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-03-16T21:51:33.000Z","updated_at":"2024-08-17T16:06:33.000Z","dependencies_parsed_at":"2023-07-01T13:45:38.793Z","dependency_job_id":"db328b22-5a5a-40ef-bb00-f14240e9ec94","html_url":"https://github.com/mbland/elistman","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mbland%2Felistman","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mbland%2Felistman/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mbland%2Felistman/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mbland%2Felistman/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mbland","download_url":"https://codeload.github.com/mbland/elistman/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":228054463,"owners_count":17862129,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api-gateway","aws-lambda","aws-sam","aws-ses","dynamodb","lambda","serverless","simple-email-service"],"created_at":"2024-12-04T06:09:26.106Z","updated_at":"2024-12-04T06:09:27.093Z","avatar_url":"https://github.com/mbland.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# EListMan - Email List Manager\n\nMailing list system providing address validation and unsubscribe URIs.\n\nSource: \u003chttps://github.com/mbland/elistman\u003e\n\n[![License](https://img.shields.io/github/license/mbland/elistman.svg)](https://github.com/mbland/elistman/blob/main/LICENSE.txt)\n[![CI/CD pipeline status](https://github.com/mbland/elistman/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/mbland/elistman/actions/workflows/pipeline.yaml?branch=main)\n[![Coverage Status](https://coveralls.io/repos/github/mbland/elistman/badge.svg?branch=main)](https://coveralls.io/github/mbland/elistman?branch=main)\n\n_(Try force reloading the page to get the latest badges if this is a return\nvisit. [The browser cache may hide the latest\nresults](https://stackoverflow.com/a/37894321).)_\n\nOnly serves one list at a time as defined by deployment parameters.\n\nImplemented in [Go][] using the following [Amazon Web Services][]:\n\n- [API Gateway][]\n- [Lambda][]\n- [DynamoDB][]\n- [Simple Email Service][]\n- [Simple Notification Service][]\n- [Web Application Firewall][]\n\nUses [CloudFormation][] and the [AWS Serverless Application Model (SAM)][] for\ndeploying the Lambda function, binding to the API Gateway, managing permissions,\nand other configuration parameters.\n\nOriginally implemented to support \u003chttps://mike-bland.com/subscribe/\u003e.\n\nThe very earliest stages of the implementation were based on hints from\n[victoriadrake/simple-subscribe][], but all the code is original.\n\n## Open Source License\n\nThis software is made available as [Open Source software][oss-def] under the\n[Mozilla Public License 2.0][]. For the text of the license, see the\n[LICENSE.txt](LICENSE.txt) file.\n\n## Setup\n\n### Install tools\n\nRun the `bin/check-tools.sh` script to check that the required tools are\ninstalled.\n\n- This script will try to install some missing tools itself. If any are missing,\n  the script will provide a link to installation instructions.\n\n- **Note**: The script does _not_ check for the presence of `make`, as it comes\n  in many flavors and aliases and already ships with many operating systems. The\n  notable exception is Microsoft Windows.\n\n  However, if the `winget` command is available, you can install the\n  [GnuWin32.Make][] package:\n\n  ```cmd\n  winget install -e --id GnuWin32.Make\n  ```\n\n  Then:\n  - Press _**Win-R**_ and enter `systempropertiesadvanced` to open the _**System\n    Properties \u003e Advanced**_ pane.\n  - Click the _**Environment Variables...**_ button.\n  - Select _**Path**_ in either the _User variables_ or _System variables_ pane,\n    then click the corresponding _**Edit...**_ button.\n  - Click the _**New**_ button, then click the _**Browse...**_ button.\n  - Navigate to _**This PC \u003e Local Disk (C:) \u003e Program Files (x86) \u003e GnuWin32 \u003e bin**_.\n  - Click the _**OK**_ button, then keep clicking the _**OK**_ button until all\n    of the _**System Properties**_ panes are closed.\n\n  Make should then be available as either `make` or `make.exe`.\n\n### Configure the AWS CLI\n\nConfigure your credentials for a region from the **Email Receiving Endpoints**\nsection of [Amazon Simple Email Service endpoints and quotas][].\n\nFollow the guidance on the [AWS Command Line Interface: Quick Setup][] page if\nnecessary.\n\n### Configure AWS Simple Email Service (SES)\n\nSet up SES in the region selected in the above step. Make sure to enable DKIM\nand create a verified domain identity per [Verifying your domain for Amazon SES\nemail receiving][].\n\nCreate a [Receipt Rule Set][] and set it as Active. EListMan will add a Receipt Rule for an unsubscribe email address to this Receipt Rule Set.\n\nCreate a Receipt Rule to receive [Email notifications][] for the `postmaster`\nand `abuse` accounts, along with any other accounts that you'd like.\n\n- You can add these recipient conditions manually, which would require creating\n  a Simple Notification Service (SNS) topic manually as well.\n- Alternatively, consider using [mbland/ses-forwarder][] to automate configuring\n  the Receipt Rule Set with an appropriate Receipt Rule.\n\nWhen you're ready for the system to go live, [publish an MX record for Amazon SES email receiving][].\n\nIt's also advisable to configure your [account-level suppression list][] to\nautomatically add addresses resulting in bounces and complaints.\n\nAssuming you have your AWS CLI environment set up correctly, this should confirm\nthat SES is properly configured (with your own identity listed, of course):\n\n```json\n$ aws sesv2 list-email-identities\n\n{\n    \"EmailIdentities\": [\n        {\n            \"IdentityType\": \"DOMAIN\",\n            \"IdentityName\": \"mike-bland.com\",\n            \"SendingEnabled\": true,\n            \"VerificationStatus\": \"SUCCESS\"\n        }\n    ]\n}\n```\n\nYou can also view other account attributes, such as account suppression list\nstatus, send quotas, and send rates, via:\n\n```json\n$ aws sesv2 get-account\n\n{\n   ...\n    \"ProductionAccessEnabled\": true,\n    \"SendQuota\": {\n        \"Max24HourSend\": ...,\n        \"MaxSendRate\": ...,\n        \"SentLast24Hours\": ...\n    },\n    \"SendingEnabled\": true,\n    \"SuppressionAttributes\": {\n        \"SuppressedReasons\": [\n            \"BOUNCE\",\n            \"COMPLAINT\"\n        ]\n    },\n    \"Details\": {\n        \"MailType\": \"MARKETING\",\n        \"WebsiteURL\": \"https://mike-bland.com/\",\n        \"ContactLanguage\": \"EN\",\n        \"UseCaseDescription\": \"This is for publishing blog posts to email subscribers.\",\n        \"AdditionalContactEmailAddresses\": [\n            \"mbland@acm.org\"\n        ],\n        ...\n    }\n}\n```\n\n### Configure AWS API Gateway\n\nSet up a custom domain name in API Gateway in the region selected in the\nabove step. Create a SSL certificate in Certificate Manager for it as well.\n\n- [Setting up custom domain names for HTTP APIs][]\n\nIf done correctly, the following command should produce output resembling the\nexample:\n\n```sh\n$ aws apigatewayv2 get-domain-names\n\n{\n    \"Items\": [\n        {\n            \"ApiMappingSelectionExpression\": \"$request.basepath\",\n            \"DomainName\": \"api.mike-bland.com\",\n            \"DomainNameConfigurations\": [\n                {\n                    \"ApiGatewayDomainName\": \"\u003c...\u003e\",\n                    \"CertificateArn\": \"\u003c...\u003e\",\n                    \"DomainNameStatus\": \"AVAILABLE\",\n                    \"EndpointType\": \"REGIONAL\",\n                    \"HostedZoneId\": \"\u003c...\u003e\",\n                    \"SecurityPolicy\": \"TLS_1_2\"\n                }\n            ]\n        }\n    ]\n}\n```\n\n### Configure API Gateway to write CloudWatch logs\n\nNext, [set up an IAM role to allow the API to write CloudWatch logs][]. You only\nneed to execute the steps in the **Create an IAM role for logging to\nCloudWatch** section. One possible name for the new IAM role would be\n`ApiGatewayCloudWatchLogging`.\n\nThe last step from the above instructions is to\n\n```sh\n$ ARN=\"arn:aws:iam::...:role/ApiGatewayCloudWatchLogging\"\n$ aws apigateway update-account --patch-operations \\\n    op='replace',path='/cloudwatchRoleArn',value='$ARN'\n```\n\nIf successful, the output should resemble the following, where `\u003cARN\u003e` is the value of `$ARN` from above:\n\n```sh\n{\n    \"cloudwatchRoleArn\": \"\u003cARN\u003e\",\n    \"throttleSettings\": {\n        \"burstLimit\": ...,\n        \"rateLimit\": ...\n    },\n    \"features\": []\n}\n```\n\n_Note:_ Per the documentation for the [AWS::ApiGateway::Account CloudFormation\nentity][], \"you should only have one `AWS::ApiGateway::Account` resource per\nregion per account.\" This is why it's not included in `template.yml` in favor of\nthe one-time-per-account instructions above.\n\nHowever, if you want to try using SAM/CloudFormation to manage it, see:\n\n- [Stack Overflow: Configuring logging of AWS API Gateway - Using a SAM\n  template][]\n\n### Run tests\n\nTo make sure the local environment is in good shape, and your AWS services are\nproperly configured, run the main test suite via `make test`. (Note that the\nexample output below is slightly edited for clarity.)\n\n```sh\n$ make test\n\ngo vet -tags=all_tests ./...\ngo run honnef.co/go/tools/cmd/staticcheck -tags=all_tests ./...\ngo build -tags=all_tests ./...\n\ngo test -tags=small_tests ./...\nok      github.com/mbland/elistman/agent        0.110s\nok      github.com/mbland/elistman/db   0.392s\nok      github.com/mbland/elistman/email        0.187s\nok      github.com/mbland/elistman/handler      0.260s\nok      github.com/mbland/elistman/ops  0.461s\nok      github.com/mbland/elistman/types        0.523s\n\ngo test -tags=medium_tests -count=1 ./...\nok      github.com/mbland/elistman/db   2.970s\nok      github.com/mbland/elistman/email        1.150s\n\ngo test -tags=contract_tests -count=1 ./db -args -awsdb\nok      github.com/mbland/elistman/db   44.264s\n```\n\nIf you're using [Visual Studio Code][], you can run all but the last test via\nthe **Test: Run All Tests** command (`testing.runAll`). The default keyboard shortcut is **⌘; A**.\n\n- The project VS Code configuration is in\n  [.vscode/settings.json](.vscode/settings.json).\n- For other helpful testing-related keyboard shortcuts, press **⌘K ⌘S**, then\n  search for `testing`.\n\n#### Test sizes\n\nThe tests are divided into suites of varying [test sizes][], described below,\nusing [Go build constraints][] (a.k.a. \"build tags\"). These constraints are\nspecified on the first line of every test file:\n\n```sh\n$ head -n1 */*_test.go\n\n==\u003e agent/agent_test.go \u003c==\n//go:build small_tests || all_tests\n\n# ...snip...\n\n# See \"Test coverage\" section below for an explanation of the\n# dynamodb_contract_test build constraints.\n==\u003e db/dynamodb_contract_test.go \u003c==\n//go:build ((medium_tests || contract_tests) \u0026\u0026 !no_coverage_tests) || coverage_tests || all_tests\n\n# ...snip...\n\n==\u003e email/mailer_contract_test.go \u003c==\n//go:build medium_tests || contract_tests || all_tests\n\n# ...etc...\n```\n\n##### Small tests\n\nThe `small_tests` all run locally, with no external dependencies. These tests\ncover all fine details and error conditions.\n\n##### Medium/contract tests\n\nEach of the `medium_tests` exercises integration with specific dependencies.\nMost of these dependencies are actual, live AWS services that require a network\nconnection.\n\nThese tests are designed to set up required state and clean up any side effects.\nOther than ensuring the network is available, and the required resources are\nrunning and accessible, no external intervention is necessary.\n\n`medium_tests` validate high level use cases and fundamental assumptions, _not_\nexhaustive details and error conditions. That's what the `small_tests` are for,\nresulting in fewer, less complicated, faster, and more stable `medium_tests`.\n\nEach of the `contract_tests` are also `medium_tests`. In fact, it's arguable\nthat these tags are redundant, but I want the reader to contemplate both\nconcepts and their equivalence.\n\nThe medium/contract tests in `db/dynamodb_contract_test.go` run against:\n\n- a local [Docker][] container running the [amazon/dynamodb-local][] image when\n  run without the `-awsdb` flag\n  - e.g. When run via `go test -tags=medium_tests -count=1 ./...`, in VS Code\n    via **⌘; A**, or in CI via `-tags=coverage_tests`, described below.\n- the actual DynamoDB for your AWS account when run with the `-awsdb` flag\n  - e.g. When run via `go test -tags=contract_tests -count=1 ./db -args -awsdb`\n\n_Note:_ `-count=1` is the Go idiom to ensure tests are run with caching\ndisabled, per `go help testflag`.\n\n##### Large tests and smoke tests\n\nThere are no end-to-end `large_tests` yet, outside of `bin/smoke-tests.sh`. The\nsmoke tests are described below, as are the plans for adding end-to-end tests\none day.\n\n#### Test coverage\n\nTo check code coverage, you can run:\n\n```sh\n$ make coverage\n\ngo test -covermode=count -coverprofile=coverage.out \\\n          -tags=small_tests,coverage_tests ./...\n\nok  github.com/mbland/elistman/agent    0.351s  coverage: 100.0% of statements\nok  github.com/mbland/elistman/db       3.214s  coverage: 100.0% of statements\nok  github.com/mbland/elistman/email    0.539s  coverage: 100.0% of statements\nok  github.com/mbland/elistman/handler  0.613s  coverage: 100.0% of statements\nok  github.com/mbland/elistman/ops      0.457s  coverage: 100.0% of statements\nok  github.com/mbland/elistman/types    0.675s  coverage: 100.0% of statements\n\ngo tool cover -html=coverage.out\n[ ...opens default browser with HTML coverage results... ]\n```\n\nYou can also check coverage in VS Code by searching for the **Go: Toggle Test\nCoverage in Current Package** command via **Show All Commands** (⇧⌘P).\n\nNote that `db/dynamodb_contract_test.go` is the one and only `medium_test` that\nwe need for test coverage purposes. It contains the `coverage_tests` build\nconstraint, enabling the CI pipeline to collect its coverage data without\nrunning other `medium_tests`.\n\n### Build the `elistman` CLI\n\nBuild the `elistman` command line interface program in the root directory via:\n\n```sh\ngo build\n```\n\nRun the command and check the output to see if it was successful:\n\n```sh\n$ ./elistman -h\n\nMailing list system providing address validation and unsubscribe URIs\n\nSee the https://github.com/mbland/elistman README for details.\n\nTo create a table:\n  elistman create-subscribers-table TABLE_NAME\n\nTo see an example of the message input JSON structure:\n  elistman preview --help\n\nTo preview a raw message before sending, where `generate-email` is any\nprogram that creates message input JSON:\n  generate-email | elistman preview\n\nTo send an email to the list, given the STACK_NAME of the EListMan instance:\n  generate-email | elistman send -s STACK_NAME\n\nUsage:\n  elistman [command]\n\nAvailable Commands:\n  [...commands snipped...]\n\nFlags:\n  -h, --help      help for elistman\n  -v, --version   version for elistman\n\nUse \"elistman [command] --help\" for more information about a command.\n```\n\n### Create the DynamoDB table\n\nRun `elistman create-subscribers-table \u003cTABLE_NAME\u003e` to create the DynamoDB\ntable, replacing `\u003cTABLE_NAME\u003e` with a table name of your choice. Then run `aws\ndynamodb list-tables` to confirm that the new table is present.\n\n### Create the configuration file\n\nCreate the `deploy.env` configuration file in the root directory containing the\nfollowing environment variables (replacing each value with your own as\nappropriate):\n\n```sh\n# This will be the name of the CloudFormation stack. The `--stack-name` flag of\n# `elistman` CLI commands will require this value.\nSTACK_NAME=\"mike-blands-blog-example\"\n\n# This is the domain name configured in the \"Configure AWS API Gateway\" step.\nAPI_DOMAIN_NAME=\"api.mike-bland.com\"\n\n# This will be the first component of the EListMan API endpoints after the\n# hostname, e.g., api.mike-bland.com/email/subscribe.\nAPI_MAPPING_KEY=\"email\"\n\n# The domain from which emails will be sent. This should likely match the\n# website on which the subscription form appears.\nEMAIL_DOMAIN_NAME=\"mike-bland.com\"\n\n# The proper name of the website from which emails will appear to be sent. It\n# need not match to the site's \u003ctitle\u003e exactly, but should clearly describe what\n# subscribers expect.\nEMAIL_SITE_TITLE=\"Mike Bland's blog\"\n\n# The proper name of the email sender. It need not match EMAIL_SITE_TITLE, but\n# again, should not surprise subscribers.\nSENDER_NAME=\"Mike Bland's blog\"\n\n# The username of the email sender. The full address will be of the form:\n# SENDER_USER_NAME@EMAIL_DOMAIN_NAME, e.g., posts@mike-bland.com.\nSENDER_USER_NAME=\"posts\"\n\n# The username of the unsubscribe email recipient. The full address will be of\n# the form: UNSUBSCRIBE_USER_NAME@EMAIL_DOMAIN_NAME, e.g.,\n# unsubscribe@mike-bland.com.\nUNSUBSCRIBE_USER_NAME=\"unsubscribe\"\n\n# The path to the unsubscribe form relative to EMAIL_DOMAIN_NAME. See the\n# \"Understand the {{UnsubscribeUrl}} template\" and \"Publish your HTML\n# unsubscribe form\" sections below.\nUNSUBSCRIBE_FORM_PATH=\"/unsubscribe\"\n\n# The name of the Receipt Rule Set created in the \"Configure AWS Simple Email\n# Service (SES)\" step.\nRECEIPT_RULE_SET_NAME=\"mike-bland.com\"\n\n# The name of the DynamoDB table created via `elistman create-subscribers-table`\n# in the \"Create the DynamoDB table\" step.\nSUBSCRIBERS_TABLE_NAME=\"\u003cTABLE_NAME\u003e\"\n\n# Percentage of daily quota to consume before self-limiting bulk sends via\n# `elistman send -s STACK_NAME`.  See the \"Send rate throttling and send quota\n# capacity limiting\" step for a detailed description. (Does not apply when\n# running `elistman send` with specific subscriber addresses specified on the\n# command line.)\nMAX_BULK_SEND_CAPACITY=\"0.8\"\n\n# EListMan will redirect API requests to the following URLs according to the \n# \"Algorithms\" described below.\nINVALID_REQUEST_PATH=\"/subscribe/malformed.html\"\nALREADY_SUBSCRIBED_PATH=\"/subscribe/already-subscribed.html\"\nVERIFY_LINK_SENT_PATH=\"/subscribe/confirm.html\"\nSUBSCRIBED_PATH=\"/subscribe/hello.html\"\nNOT_SUBSCRIBED_PATH=\"/unsubscribe/not-subscribed.html\"\nUNSUBSCRIBED_PATH=\"/unsubscribe/goodbye.html\"\n```\n\n### Run smoke tests locally\n\n`bin/smoke-test.sh` invokes `curl` to send HTTP requests to the running Lambda,\nall of which expect an error response without any side effects (save for\nlogging).\n\nTo check that your configuration works locally, you'll need two separate\nterminal windows to run `bin/smoke-test.sh`. In the first, run:\n\n```sh\n$ make run-local\n\n[ ...validates template.yml, builds lambda, etc... ]\nbin/sam-with-env.sh deploy.env local start-api --port 8080\n[ ...more output... ]\nYou can now browse to the above endpoints to invoke your functions....\n2023-05-29 16:08:04 WARNING: This is a development server....\n * Running on http://127.0.0.1:8080\n2023-05-29 16:08:04 Press CTRL+C to quit\n```\n\nIn the next terminal, run:\n\n```sh\n$ ./bin/smoke-test ./deploy.env --local\n\nINFO: SUITE: Not found (403 locally, 404 in prod)\nINFO: TEST: 1 — invalid endpoint not found\nExpect 403 from: POST http://127.0.0.1:8080/foobar/mbland%40acm.org\n\ncurl -isS -X POST http://127.0.0.1:8080/foobar/mbland%40acm.org\n\nHTTP/1.1 403 FORBIDDEN\nServer: Werkzeug/2.3.4 Python/3.8.16\nDate: Mon, 29 May 2023 20:19:57 GMT\nContent-Type: application/json\nContent-Length: 43\nConnection: close\n\n{\"message\":\"Missing Authentication Token\"}\n\nPASSED: 1 — invalid endpoint not found:\n    status: 403\n\nINFO: TEST: 2 — /subscribe with trailing component not found\nExpect 403 from: POST http://127.0.0.1:8080/subscribe/foobar\n\n[ ...more test output/results... ]\n\nPASSED: 6 — invalid UID for /unsubscribe:\n    status: 400\n\nPASSED: All 6 smoke tests passed!\n```\n\nThen enter CTRL-C in the first window to stop the local SAM Lambda server.\n\n### Understand the danger of spam bots and the need for a CAPTCHA\n\nBefore deploying to production, we need to talk about spam.\n\nThe EListMan system tries to validate email addresses through its own up front\nanalysis and by sending validation links to subscribers. However, opportunistic\nspam bots can still—and will—submit many valid email addresses without either\nthe knowledge or consent of the actual owner.\n\nFortunately, the validation link mechanism prevents most bogus subscriptions,\nand [DynamoDB's Time To Live feature][] cleans them from the database\nautomatically. A bounce or complaint also notifies the EListMan Lambda to remove\nthe address and add it to the [account-level suppression list][]. The\nsuppression list ensures the system won't send to that address again, even if\nsomeone attempts to resubmit it.\n\nThis means most bogus subscriptions will not pollute the verified subscriber\nlist, and such recipients will not receive further emails. However, generating\nthese bogus subscriptions still consumes resources, and their verification\nemails can yield bounces and complaints that will harm your [SES reputation\nmetrics][].\n\nHaving learned this the hard (naïve) way, I recommend using a [CAPTCHA][] to\nprevent spam bot abuse:\n\n- When I first published my EListMan subscription form, my instance received\n  dozens of bogus subscription requests a day—before I'd even announced it on my\n  blog. (The form had been available before, but used a different subscription\n  system.)\n- After deploying a CAPTCHA, the number of bogus subscriptions dropped to zero.\n  (I hope I hadn't inadvertently been allowing subscription verification spam\n  all those years before....)\n\n### Decide whether or not to use the AWS WAF CAPTCHA\n\nEListMan's CloudFormation/SAM template configures an AWS Web Application\nFirewall (WAF) CAPTCHA, creating one Web ACL and one Rule associated with it.\nIf you choose to use it, note that it does incur additional charges. See [AWS\nWAF Pricing][] for details.\n\nIf you choose not to use it, comment out or delete the `WebAcl` and\n`WebAclAssociation` resources in [template.yml](./template.yml).\n\n### Generate an AWS Web Application Firewall CAPTCHA API KEY (optional)\n\nTo use EListMan's Web ACL configuration, you'll need to [generate an API key for\nthe CAPTCHA API][]. Include whichever domain will serve the submission form in\nthe list of domains used to generate the API key.\n\nThe default EListMan configuration expects this domain to be the same as\n`EMAIL_DOMAIN_NAME`, described above.  If you use a different domain, set\n`WebAcl \u003e Properties \u003e TokenDomains` in [template.yml](./template.yml)\nappropriately.\n\n## Deployment\n\n### Deploy to AWS\n\nIf the smoke tests pass, deploy the EListMan system via:\n\n```sh\nmake deploy\n```\n\nOnce the deployment is running, run the smoke tests without the `--local` flag\nto ensure your instance is reachable:\n\n```sh\n./bin/smoke-tests.sh ./deploy.env\n```\n\n### Publish your HTML subscription form\n\nYou'll need to publish a subscription [\u0026lt;form\u0026gt;][] similar to the following,\nsubstituting `API_DOMAIN_NAME` with the custom domain name from the **Configure\nAWS API Gateway** step:\n\n```html\n\u003c!-- subscribe.html --\u003e\n\n\u003cform method=\"post\" action=\"https://API_DOMAIN_NAME/email/subscribe\"\u003e\n  \u003cinput name=\"email\" type=\"email\"\n   placeholder=\"Please enter your email address.\"/\u003e\n  \u003cbutton type=\"submit\"\u003eSubscribe\u003c/button\u003e\n\u003c/form\u003e\n```\n\nHowever, as mentioned above, spam bots are a thing, even for the humblest of\nsites publicly sporting a [\u0026lt;form\u0026gt;][] element.\n\n### Generate your email submission form programmatically (optional)\n\nYou may gain extra protection from spam bots by generating the subscription form\nusing JavaScript instead of embedding a [\u0026lt;form\u0026gt;][] element directly in\nyour HTML.\n\nIn other words, instead of embedding the [\u0026lt;form\u0026gt;][] directly in your\nsubscription page as shown above, use something like this:\n\n```html\n\u003c!-- subscribe.html --\u003e\n\n\u003cdiv class=\"subscribe-form\"\u003e\n  \u003cbutton\u003eShow subscribe form\u003c/button\u003e\n\u003c/div\u003e\n```\n\n```js\n// subscribe.js\n\n\"use strict\";\n\ndocument.addEventListener(\"DOMContentLoaded\", () =\u003e {\n  var container = document.querySelector(\".subscribe-form\")\n\n  var showForm = () =\u003e {\n    var f = document.createElement(\"form\")\n    // The following should generate the value for API_DOMAIN_NAME.\n    var api_domain_name = [\"my\", \"api\", \"com\"].join(\".\")\n    f.action = [\"https:\", \"\", api_domain_name, \"email\", \"subscribe\"].join(\"/\")\n    f.method = \"post\"\n\n    var i = document.createElement(\"input\")\n    i.name = \"email\"\n    i.type = \"email\"\n    i.placeholder = \"Please enter your email address.\"\n    f.appendChild(i)\n\n    var s = document.createElement(\"button\")\n    s.type = \"submit\"\n    s.appendChild(document.createTextNode(\"Subscribe\"))\n    f.appendChild(s)\n\n    container.parentNode.replaceChild(f, container)\n  }\n\n  container.querySelector(\"button\").addEventListener('click', showForm)\n})\n```\n\n### Integrate the CAPTCHA into your subscription form (optional)\n\nOf course, the ultimate protection would be to use an AWS WAF CAPTCHA to protect\nthe `/subscribe` API endpoint.\n\nUsing the same HTML from above, the code below will [render the AWS WAF CAPTCHA\npuzzle][] when the subscriber clicks the button.  When they solve the puzzle, it\nwill then reveal the submission form.\n\nRemember to substitute `YOUR_AWS_WAF_CAPTCHA_API_KEY` with your own API key:\n\n```js\n// subscribe.js\n\n\"use strict\";\n\ndocument.addEventListener(\"DOMContentLoaded\", () =\u003e {\n  var container = document.querySelector(\".subscribe-form\")\n\n  var showForm = () =\u003e {\n    // Same implementation as above\n  }\n\n  container.querySelector(\"button\").addEventListener('click', () =\u003e {\n    AwsWafCaptcha.renderCaptcha(container, {\n      apiKey: YOUR_AWS_WAF_CAPTCHA_API_KEY,\n      onSuccess: showForm,\n      dynamicWidth: true,\n      skipTitle: true\n    });\n  })\n})\n```\n\n### Understand the `{{UnsubscribeUrl}}` template\n\nThe `{{UnsubscribeUrl}}` generated for each recipient will be of the format:\n\n- `https://${EMAIL_DOMAIN_NAME}/${UNSUBSCRIBE_FORM_PATH}?email=\u003cemail\u003e\u0026uid=\u003cuid\u003e`\n\nwhere:\n\n- `\u003cemail\u003e` is the recipient's query encoded email address\n- `\u003cuid\u003e` is the recipient's query encoded user ID generated by the system\n\nFor example:\n\n- `https://mike-bland.com/unsubscribe?email=foo%40bar.com\u0026uid=00000000-1111-2222-3333-444444444444`\n\nFor more background on URI encoding:\n\n- [RFC 3986: Uniform Resource Identifier (URI): Generic Syntax][]\n- [MDN: encodeURI()][]\n\n### Publish your HTML unsubscribe form\n\nYou'll need to publish a page with an unsubscribe [\u0026lt;form\u0026gt;][] at the\nlocation `https://${EMAIL_DOMAIN_NAME}/${UNSUBSCRIBE_FORM_PATH}`. This form will\nallow the user to confirm they really intend to unsubscribe.\nUse JavaScript to fill out the [\u0026lt;form\u0026gt;][] using the `email` and `uid` URL\nquery parameters provided when the user clicks on their unique\n`{{UnsubscribeUrl}}`.\n\nFor example, following the same pattern as the **Generate your email submission\nform programmatically (optional)** section above:\n\n```html\n\u003c!-- unsubscribe.html --\u003e\n\n\u003ch2\u003eUnsubscribe\u003c/h2\u003e\n\n\u003cdiv class=\"unsubscribe\"\u003e\u003cp\u003eIf you would like to stop receiving email\nupdates, please click the \"Unsubscribe\" link at the bottom of one of the\nemails.\u003c/p\u003e\u003c/div\u003e\n```\n\n```js\n// unsubscribe.js\n\n\"use strict\";\n\ndocument.addEventListener(\"DOMContentLoaded\", () =\u003e {\n  var params = new URLSearchParams(window.location.search)\n\n  if (!params.has(\"email\") || !params.has(\"uid\")) {\n    return\n  }\n\n  var instructions = document.querySelector(\".unsubscribe p\")\n  instructions.innerHTML = instructions.innerHTML.replace(/[\\n ]+/g, \" \")\n    .replace(\"link at the bottom of one of the emails\", \"button below\")\n\n  var f = document.createElement(\"form\")\n  // The following should generate the value for API_DOMAIN_NAME.\n  var api_domain_name = [\"my\", \"api\", \"com\"].join(\".\")\n  f.action = [\n    \"https:\", \"\", api_domain_name, \"email\", \"unsubscribe\",\n    encodeURI(params.get(\"email\")), encodeURI(params.get(\"uid\")),\n  ].join(\"/\")\n  f.method = \"post\"\n\n  var s = document.createElement(\"button\")\n  s.type = \"submit\"\n  s.appendChild(document.createTextNode(\"Unsubscribe\"))\n  f.appendChild(s)\n  instructions.parentNode.appendChild(f)\n})\n```\n\n### Subscribe and send a test email to yourself\n\nAfter deploying EListMan and publishing your subscription form, use the form to\nsubscribe to the list. Then you can run the following command to send a test\nemail to yourself (replacing `STACK_NAME` and `MY_EMAIL_ADDRESS` as\nappropriate):\n\n```sh\n$ ./bin/generate-test-message.sh ./deploy.env |\n    ./elistman send -s STACK_NAME MY_EMAIL_ADDRESS\n```\n\n### Send a production email to the list\n\nRun `./elistman send -h` to see an example email:\n\n```sh\n$ ./elistman send -h \n\nReads a JSON object from standard input describing a message:\n\n  {\n    \"From\": \"Foo Bar \u003cfoobar@example.com\u003e\",\n    \"Subject\": \"Test object\",\n    \"TextBody\": \"Hello, World!\",\n    \"TextFooter\": \"Unsubscribe: {{UnsubscribeUrl}}\",\n    \"HtmlBody\": \"\u003c!DOCTYPE html\u003e\u003chtml\u003e\u003chead\u003e\u003c/head\u003e\u003cbody\u003eHello, World!\u003cbr/\u003e\",\n    \"HtmlFooter\": \"\u003ca href='{{UnsubscribeUrl}}'\u003eUnsubscribe\u003c/a\u003e\u003c/body\u003e\u003c/html\u003e\"\n  }\n```\n\nYou will need to generate a similar JSON object to feed into the standard input\nof `./elistman send`:\n\n- `From`, `Subject`, `TextBody`, and `TextFooter` are required.\n- If `HtmlBody` is present, `HtmlFooter` must also be present.\n- `TextFooter`, and `HtmlFooter` if present, must contain one and only one\n  instance of the `{{UnsubscribeUrl}}` template. The EListMan Lambda will\n  replace this template with the unsubscribe URL unique to each subscriber.\n- `TextFooter` and `HtmlFooter` will appear on a new line immediately after\n  `TextBody` and `HtmlBody`, respectively.\n\nProvided you have a program to generate the JSON object above called\n`generate-email`, you can then send an email to the list via:\n\n```sh\ngenerate-email | ./elistman send -s STACK_NAME\n```\n\n## Development\n\nThe [Makefile](./Makefile) is very short and readable. Use it to run common\ntasks, or learn common commands from it to use as you please.\n\nFor guidance on writing Go developer documentation, see [Go Doc Comments][].\n\nThere are two ways to view the developer documentation in a web browser.\n\n### Viewing documentation with `godoc`\n\n[godoc][] is reportedly deprecated, but still works well. See:\n\n- [golang/go: x/tools/cmd/godoc: document as deprecated #49212](https://github.com/golang/go/issues/49212)\n- [349051: cmd/godoc: deprecate and point to cmd/pkgsite](https://go-review.googlesource.com/c/tools/+/349051)\n\n```sh\n# Install the godoc tool.\n$ go install -v golang.org/x/tools/cmd/godoc@latest\n\n# Serve documentation from the local directory at http://localhost:6060.\n$ godoc -http=:6060\n```\n\nYou can then view the EListMan docs locally at:\n\n- \u003chttp://localhost:6060/pkg/github.com/mbland/elistman/\u003e\n\nOne of the nice features of `godoc` is that you can view documentation for\nunexported symbols by adding `?m=all` to the URL. For example:\n\n- \u003chttp://localhost:6060/pkg/github.com/mbland/elistman/?m=all\u003e\n\n### Viewing documentation with `pkgsite`\n\n[pkgsite][] is the newer development documentation publishing system.\n\n```sh\n# Install the pkgsite tool.\n$ go install golang.org/x/pkgsite/cmd/pkgsite@latest\n\n# Serve documentation from the local directory at http://localhost:8080.\n$ pkgsite\n```\n\nYou can then view the EListMan docs locally at:\n\n- \u003chttp://localhost:8080/github.com/mbland/elistman\u003e\n\nNote that, unlike `godoc`, `pkgsite` doesn't provide an option to serve documentation for unexported symbols.\n\n## URI Schema\n\n- `https://\u003capi_hostname\u003e/\u003croute_key\u003e/\u003coperation\u003e`\n- `mailto:\u003cunsubscribe_user_name\u003e@\u003cemail_domain_name\u003e?subject=\u003cemail\u003e%20\u003cuid\u003e`\n\nWhere:\n\n- `\u003capi_hostname\u003e`: Hostname for the API Gateway instance\n- `\u003croute_key\u003e`: Route key for the API Gateway\n- `\u003coperation\u003e`: Endpoint for the list management operation:\n  - `/subscribe`\n  - `/verify/\u003cemail\u003e/\u003cuid\u003e`\n  - `/unsubscribe/\u003cemail\u003e/\u003cuid\u003e`\n- `\u003cemail\u003e`: Subscriber's email address\n- `\u003cuid\u003e`: Identifier assigned to the subscriber by the system\n- `\u003cunsubscribe_user_name\u003e`: The username receiving unsubscribe emails,\n  typically `unsubscribe`, set via `UNSUBSCRIBE_USER_NAME`.\n- `\u003cemail_domain_name\u003e`: Hostname serving as an SES verified identity for\n  sending and receiving email, set via `EMAIL_DOMAIN_NAME`\n\nSee also:\n\n- [RFC 6068: The 'mailto' URI Scheme][]\n\n## Algorithms\n\nUnless otherwise noted, all responses will be [HTTP 303 See Other][], with the\ntarget page specified in the [Location HTTP header][].\n\n- The one exception will be unsubscribe requests from mail clients using the\n  `List-Unsubscribe` and `List-Unsubscribe-Post` email headers.\n\n### Generating a new subscriber verification link\n\n1. An HTTP request from the API Gateway comes in, containing the email address\n   of a potential subscriber.\n1. Validate the email address.\n   1. Parse the name as closely as possible to [RFC 5322 Section 3.2.3][] via [net/mail.ParseAddress][].\n   1. Reject any common aliases, like \"no-reply\" or \"postmaster.\"\n   1. Check the MX records of the host by:\n      1. Doing a reverse lookup on each mail host's IP addresses.\n      1. Looking up the IP addresses of the hosts returned by the reverse lookup.\n      1. Confirming at least one reverse lookup host IP address matches a mail\n         host IP address.\n   1. If it fails validation, return the `INVALID_REQUEST_PATH`.\n1. Look for an existing DynamoDB record for the email address.\n   1. If it exists, return the `VERIFY_LINK_SENT_PATH` for `Pending` subscribers\n      and `ALREADY_SUBSCRIBED_PATH` for `Verified` subscribers.\n1. Generate a UID.\n1. Write a DynamoDB record containing the email address, the UID, a timestamp,\n   and with `SubscriberStatus` set to `Pending`.\n1. Generate a verification link using the email address and UID.\n1. Send the verification link to the email address.\n   1. If the mail bounces or fails to send, return the `INVALID_REQUEST_PATH`.\n1. Return the `VERIFY_LINK_SENT_PATH`.\n\n### Responding to a subscriber verification link\n\n1. An HTTP request from the API Gateway comes in, containing a subscriber's\n   email address and UID.\n1. Check whether there is a record for the email address in DynamoDB.\n   1. If not, return the `NOT_SUBSCRIBED_PATH`.\n1. Check whether the UID matches that from the DynamoDB record.\n   1. If not, return the `NOT_SUBSCRIBED_PATH`.\n1. If the subscriber's status is `Verified`, return the\n   `ALREADY_SUBSCRIBED_PATH`.\n1. Set the `SubscriberStatus` of the record to `Verified`.\n1. Return the `SUBSCRIBED_PATH`.\n\n### Responding to an unsubscribe request\n\n1. Either an HTTP Request from the API Gateway or a mailto: event from SES comes\n   in, containing a subscriber's email address and UID.\n1. Check whether there is a record for the email address in DynamoDB.\n   1. If not, return the `NOT_SUBSCRIBED_PATH`.\n1. Check whether the UID matches that from the DynamoDB record.\n   1. If not, return the `NOT_SUBSCRIBED_PATH`.\n1. Delete the DynamoDB record for the email address.\n1. If the request was an HTTP Request:\n   1. If it uses the `POST` method, and the data contains\n      `List-Unsubscribe=One-Click`, return [HTTP 204 No Content][].\n   1. Otherwise return the `UNSUBSCRIBED_PATH` page.\n\n### Expiring unused subscriber verification links\n\n[DynamoDB's Time To Live feature][] will eventually remove expired pending subscriber records after 24 hours.\n\n### Send rate throttling and send quota capacity limiting\n\nEListMan calls the SES v2 `getAccount` API method once a minute to monitor\nsending quotas and to adjust the send rate. Every individual message sent,\nincluding both subscription verification messages and messages sent to the list,\nwill honor the current send rate.\n\nThe `MAX_BULK_SEND_CAPACITY` parameter specifies what percentage of the 24 hour\nsend quota may be used for sending emails to the list. This helps avoid\nexceeding the daily quota before a message has been sent to all subscribers.\n`elistman send` will fail, before sending an email, if the percentage of the\ndaily send quota specified by `MAX_BULK_SEND_CAPACITY` has already been\nconsumed.\n\nThe default is to use 80% of the available daily send quota for list messages,\nexpressed as `MAX_BULK_SEND_CAPACITY=\"0.8\"`. The remaining 20% acts as a buffer.\nFor example, for a quota of 50,000 messages, up to 40,000 (50,000 * 0.8) are\navailable to `elistman send` within a 24 hour period.\n\nNote that this mechanism tries to prevent the operator from accidentally\nexceeding the 24 hour quota, but it's not foolproof. The operator is ultimately\nresponsible for ensuring that `elistman send` won't exceed the quota if\n`MAX_BULK_SEND_CAPACITY` hasn't yet been reached, or for tuning it accordingly.\n\nBuilding on the previous example, if there are 17,000 subscribers:\n\n- The first `elistman send` consumes 17,000 of the quota.\n- The second `elistman send` consumes the next 17,000 of the quota, for a total\n  of 34,000.\n- The third `elistman send` will proceed, since 34,000 is less than the 40,000\n  calculated by `MAX_BULK_SEND_CAPACITY=\"0.8\"`. However, it will consume 17,000 more of the quota, for 51,000 total, exceeding the 50,000 quota.\n\nSubscription verification messages are not affected by the\n`MAX_BULK_SEND_CAPACITY` constraint. The buffer defined by\n`MAX_BULK_SEND_CAPACITY` can ensure that there is always daily send quota\navailable for such messages.\n\n- [Managing your Amazon SES sending limits][]\n- [Errors related to the sending quotas for your Amazon SES account][]\n- [How to handle a \"Throttling – Maximum sending rate exceeded\" error][]\n- [How to Automatically Prevent Email Throttling when Reaching Concurrency Limit][]\n\n## Unimplemented/possible future features\n\n### Automated End-to-End tests\n\nThis is something I _really_ want to pull off, but without blocking the first\nrelease.\n\nHere is what I anticipate the implementation will involve (beyond some of the existing cases in [bin/smoke-test.sh](./bin/smoke-test.sh)):\n\n#### Test Setup\n\n- Create a new random test username.\n- Create a S3 bucket for the emails received by the random test user.\n- Create a receipt rule for the active rule set on the domain to send emails to\n  the new random test username to the S3 bucket.\n  - Add the random test username to the recipient conditions.\n  - Add an action to write to the S3 bucket\n- Bring up a CloudFormation/Serverless Application Model stack defining these\n  resources.\n\n#### Execution\n\nFor each permutation described below:\n\n- Send a request to subscribe the valid random username.\n  - _Note:_ The `/subscribe` endpoint is CAPTCHA-protected on the dev and prod\n    instances. We may need to bring up an alternate API Gateway instance\n    for the test without CAPTCHA protection, or call `ProdAgent.Subscribe()`\n    through another method.\n- Read the S3 bucket to get the validation URL.\n- Request the validation URL.\n- Form an unsubscribe request (either URL or mailto) from the validation URL.\n\n#### Permutations\n\n- Subscribe via urlencoded params, unsubscribe via urlencoded params\n- Subscribe via form-data, unsubscribe via form-data params\n- Subscribe via urlencoded params, unsubscribe via email\n- Try to resubscribe to expect an `ALREADY_SUBSCRIBED_PATH` response.\n- Modify the UID in the verification URL to expect an `INVALID_REQUEST_PATH`\n  response.\n\n#### Teardown\n\n- Tear down the stack, which will:\n  - Tear down the receipt rules\n  - Tear down the test bucket\n\n## References\n\n- [Building Lambda functions with Go][]\n- [Using AWS Lambda with other services][]\n- [Using AWS Lambda with Amazon API Gateway][]\n- [aws/aws-sdk-go][]\n- [aws/aws-lambda-go][]\n- [Blank AWS Lambda function in Go][]\n- [Installing or updating the latest version of the AWS CLI][]\n- [The Complete AWS Sam Workshop][]\n- [AWS Serverless Application Model (AWS SAM) specification][]\n- [AWS Serverless Application Model (SAM) Version 2016-10-31][]\n- [AWS Serverless Application Model (AWS SAM) Documentation][]\n- [AWS CloudFormation Parameters][]\n- [AWS CloudFormation Template Reference][]\n- [AWS::Serverless::HttpApi][]\n- [Setting up custom domain names for REST APIs][]\n- [AWS::Serverless::Connector][]\n- [How can I set up a custom domain name for my API Gateway API?][]\n- [Tutorial: Build a CRUD API with Lambda and DynamoDB][]\n- [AWS SAM policy templates][]\n- [Serverless Land: Lambda to SES][]\n- [How to use AWS secret manager and SES with AWS SAM][]\n- [AWS SDK for Go V2][]\n- [AWS Lambda function handler in Go][]\n- [aws-lambda-go APIGatewayV2 event structures][]\n- [aws-lambda-go APIGatewayV2 event example][]\n- [Working with AWS Lambda proxy integrations for HTTP APIs][]\n- [Using templates to send personalized email with the Amazon SES API][]\n- [AWS SES Sample incoming email event][]\n- [AWS CloudFormation AWS::SES::ReceiptRuleSet][]\n- [AWS CloudFormation AWS::SES::ReceiptRule][]\n- [AWS SES Invoke Lambda function action][]\n- [Using AWS Lambda with Amazon SES][]\n- [aws/aws-lambda-go/events/README_SES.md][]\n- [Regions and Amazon SES][]\n- [DMARC GUIDE | DMARC: What is DMARC?][]\n- [Packing multiple binaries in a Golang package][]\n- [One-Click List-Unsubscribe Header – RFC 8058][]\n- [The Use of URLs as Meta-Syntax for Core Mail List Commands and their Transport through Message Header Fields - RFC 2369][RFC 2369]\n- [Signaling One-Click Functionality for List Email Headers - RFC 8058][RFC 8058]\n- [List-Unsubscribe header critical for sustained email delivery][]\n- [The Email Marketers Guide to Using List-Unsubscribe][]\n- [List Unsubscribe Header in Email][]\n- [Prevent mail to Gmail users from being blocked or sent to spam][]\n- [Stack Overflow: Post parameter in path or in body][]\n- [How to Verify Email Address Without Sending an Email][]\n- [25, 2525, 465, 587, and Other Numbers: All About SMTP Ports][]\n- [How to Choose the Right SMTP Port (Port 25, 587, 465, or 2525)][]\n- [Which SMTP port should I use? Understanding ports 25, 465 \u0026 587][]\n- [Using curl to send email][]\n- [Email Sender Reputation Made Simple][]\n- [Why Go: Command-line Interfaces (CLIs)][]\n- [spf13/cobra][]\n- [AWS Lambda function logging in Go][]\n- [Working with stages for HTTP APIs][]\n- [AWS Lambda function errors in Go][]\n- [RFC 9110: HTTP Semantics][]\n- [Golang Auto Build Versioning][]\n- [jasonmf/go-embed-version][]\n- [Using ldflags to Set Version Information for Go Applications][]\n- [go tool link][] (also `go tool link -help`)\n- [A better way than “ldflags” to add a build version to your Go binaries][]\n- [AWS Lambda function versions][]\n- [Sending test emails in Amazon SES with the simulator][]\n- [Setting up event notification for Amazon SES][]\n- [Receiving Amazon SES notifications using Amazon SNS][]\n- [Contents of event data that Amazon SES publishes to Amazon SNS][]\n- [How email sending works in Amazon SES][]\n- [Specifying a configuration set when you send email][]\n- [RFC 2782: A DNS RR for specifying the location of services (DNS SRV)][]\n- [RFC 4409: Message Submission for Mail][]\n- [RFC 6186: Use of SRV Records for Locating Email Submission/Access Services][]\n- [Multipurpose Internet Mail Extensions (MIME)][MIME]\n- [Does the presence of a Content-ID header in an email MIME mean that the attachment must be embedded?][]\n- [RFC 2183: Communicating Presentation Information in Internet Messages: The Content-Disposition Header Field][]\n- [RFC 2392: Content-ID and Message-ID Uniform Resource Locators][]\n- [The precise format of Content-Id header][]\n- [RFC 7103: Advice for Safe Handling of Malformed Messages][]\n\n[Go]: https://go.dev/\n[Amazon Web Services]: https://aws.amazon.com\n[API Gateway]: https://aws.amazon.com/api-gateway/\n[Lambda]: https://aws.amazon.com/lambda/\n[DynamoDB]: https://aws.amazon.com/dynamodb/\n[Simple Email Service]: https://aws.amazon.com/ses/\n[Simple Notification Service]: https://aws.amazon.com/sns/\n[Web Application Firewall]: https://aws.amazon.com/waf/\n[CloudFormation]: https://aws.amazon.com/cloudformation/\n[AWS Serverless Application Model (SAM)]: https://aws.amazon.com/serverless/sam/\n[victoriadrake/simple-subscribe]: https://github.com/victoriadrake/simple-subscribe/\n[GnuWin32.Make]: https://winget.run/pkg/GnuWin32/Make\n[Amazon Simple Email Service endpoints and quotas]: https://docs.aws.amazon.com/general/latest/gr/ses.html\n[AWS Command Line Interface: Quick Setup]: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html\n[Email notifications]: https://docs.aws.amazon.com/sns/latest/dg/sns-email-notifications.html\n[mbland/ses-forwarder]: https://github.com/mbland/ses-forwarder\n[publish an MX record for Amazon SES email receiving]: https://docs.aws.amazon.com/ses/latest/dg/receiving-email-mx-record.html\n[account-level suppression list]: https://docs.aws.amazon.com/ses/latest/dg/sending-email-suppression-list.html\n[Verifying your domain for Amazon SES email receiving]: https://docs.aws.amazon.com/ses/latest/dg/receiving-email-verification.html\n[Receipt Rule Set]: https://docs.aws.amazon.com/ses/latest/dg/receiving-email-receipt-rules-console-walkthrough.html\n[Setting up custom domain names for HTTP APIs]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-custom-domain-names.html\n[set up an IAM role to allow the API to write CloudWatch logs]: https://repost.aws/knowledge-center/api-gateway-cloudwatch-logs\n[AWS::ApiGateway::Account CloudFormation entity]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-account.html\n[Stack Overflow: Configuring logging of AWS API Gateway - Using a SAM template]: https://stackoverflow.com/a/74985768\n[test sizes]: https://mike-bland.com/making-software-quality-visible#the-test-pyramid\n[Go build constraints]: https://pkg.go.dev/cmd/go#hdr-Build_constraints\n[DynamoDB's Time To Live feature]: https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html\n[SES reputation metrics]: https://docs.aws.amazon.com/ses/latest/dg/monitor-sender-reputation.html\n[CAPTCHA]: https://docs.aws.amazon.com/waf/latest/developerguide/waf-captcha-puzzle.html\n[AWS WAF Pricing]: https://aws.amazon.com/waf/pricing/\n[generate an API key for the CAPTCHA API]: https://docs.aws.amazon.com/waf/latest/developerguide/waf-js-captcha-api-key.html\n[\u0026lt;form\u0026gt;]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form\n[render the AWS WAF CAPTCHA puzzle]: https://docs.aws.amazon.com/waf/latest/developerguide/waf-js-captcha-api-render.html\n[RFC 3986: Uniform Resource Identifier (URI): Generic Syntax]: https://www.rfc-editor.org/rfc/rfc3986.html\n[MDN: encodeURI()]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/encodeURI\n[Docker]: https://www.docker.com\n[amazon/dynamodb-local]: https://hub.docker.com/r/amazon/dynamodb-local\n[Visual Studio Code]: https://code.visualstudio.com\n[Go Doc Comments]: https://go.dev/doc/comment\n[godoc]: https://pkg.go.dev/golang.org/x/tools/cmd/godoc\n[pkgsite]: https://pkg.go.dev/golang.org/x/pkgsite/cmd/pkgsite\n[RFC 6068: The 'mailto' URI Scheme]: https://www.rfc-editor.org/rfc/rfc6068\n[HTTP 303 See Other]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/303\n[Location HTTP Header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location\n[RFC 5322 Section 3.2.3]: https://datatracker.ietf.org/doc/html/rfc5322#section-3.2.3\n[net/mail.ParseAddress]: https://pkg.go.dev/net/mail#ParseAddress\n[Managing your Amazon SES sending limits]: https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas.html\n[Errors related to the sending quotas for your Amazon SES account]: https://docs.aws.amazon.com/ses/latest/dg/manage-sending-quotas-errors.html\n[How to handle a \"Throttling – Maximum sending rate exceeded\" error]: https://aws.amazon.com/blogs/messaging-and-targeting/how-to-handle-a-throttling-maximum-sending-rate-exceeded-error/\n[How to Automatically Prevent Email Throttling when Reaching Concurrency Limit]: https://aws.amazon.com/blogs/messaging-and-targeting/prevent-email-throttling-concurrency-limit/\n[oss-def]:     https://opensource.org/osd-annotated\n[HTTP 204 No Content]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/204\n[Mozilla Public License 2.0]: https://www.mozilla.org/en-US/MPL/\n[Building Lambda functions with Go]: https://docs.aws.amazon.com/lambda/latest/dg/lambda-golang.html\n[Using AWS Lambda with other services]: https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html\n[Using AWS Lambda with Amazon API Gateway]: https://docs.aws.amazon.com/lambda/latest/dg/services-apigateway.html\n[aws/aws-sdk-go]: https://github.com/aws/aws-sdk-go\n[aws/aws-lambda-go]: https://github.com/aws/aws-lambda-go\n[Blank AWS Lambda function in Go]: https://github.com/awsdocs/aws-lambda-developer-guide/tree/main/sample-apps/blank-go\n[Installing or updating the latest version of the AWS CLI]: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html\n[The Complete AWS Sam Workshop]: https://catalog.workshops.aws/complete-aws-sam/en-US\n[AWS Serverless Application Model (AWS SAM) specification]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification.html\n[AWS Serverless Application Model (SAM) Version 2016-10-31]: https://github.com/aws/serverless-application-model/blob/master/versions/2016-10-31.md\n[AWS Serverless Application Model (AWS SAM) Documentation]: https://docs.aws.amazon.com/serverless-application-model/\n[AWS CloudFormation Parameters]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html\n[AWS CloudFormation Template Reference]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-reference.html\n[AWS::Serverless::HttpApi]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-httpapi.html\n[Setting up custom domain names for REST APIs]: https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-custom-domains.html\n[AWS::Serverless::Connector]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-resource-connector.html\n[How can I set up a custom domain name for my API Gateway API?]: https://aws.amazon.com/premiumsupport/knowledge-center/custom-domain-name-amazon-api-gateway/\n[Tutorial: Build a CRUD API with Lambda and DynamoDB]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-dynamo-db.html\n[AWS SAM policy templates]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-templates.html\n[Serverless Land: Lambda to SES]: https://serverlessland.com/patterns/lambda-ses\n[How to use AWS secret manager and SES with AWS SAM]: https://medium.com/nerd-for-tech/how-to-use-aws-secret-manager-and-ses-with-aws-sam-a93bb359d45a\n[AWS SDK for Go V2]: https://aws.github.io/aws-sdk-go-v2/\n[AWS Lambda function handler in Go]: https://docs.aws.amazon.com/lambda/latest/dg/golang-handler.html\n[aws-lambda-go APIGatewayV2 event structures]: https://github.com/aws/aws-lambda-go/blob/main/events/apigw.go\n[aws-lambda-go APIGatewayV2 event example]: https://github.com/aws/aws-lambda-go/blob/main/events/README_ApiGatewayEvent.md\n[Working with AWS Lambda proxy integrations for HTTP APIs]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html\n[Using templates to send personalized email with the Amazon SES API]: https://docs.aws.amazon.com/ses/latest/dg/send-personalized-email-api.html\n[AWS SES Sample incoming email event]: https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda-event.html\n[AWS CloudFormation AWS::SES::ReceiptRuleSet]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-receiptruleset.html\n[AWS CloudFormation AWS::SES::ReceiptRule]: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ses-receiptrule.html\n[AWS SES Invoke Lambda function action]: https://docs.aws.amazon.com/ses/latest/dg/receiving-email-action-lambda.html\n[Using AWS Lambda with Amazon SES]: https://docs.aws.amazon.com/lambda/latest/dg/services-ses.html\n[aws/aws-lambda-go/events/README_SES.md]: https://github.com/aws/aws-lambda-go/blob/main/events/README_SES.md\n[Regions and Amazon SES]: https://docs.aws.amazon.com/ses/latest/dg/regions.html#region-endpoints\n[DMARC GUIDE | DMARC: What is DMARC?]: https://dmarcguide.globalcyberalliance.org/#/dmarc/\n[Packing multiple binaries in a Golang package]: https://ieftimov.com/posts/golang-package-multiple-binaries/\n[One-Click List-Unsubscribe Header – RFC 8058]: https://certified-senders.org/wp-content/uploads/2017/07/CSA_one-click_list-unsubscribe.pdf\n[RFC 2369]: https://www.rfc-editor.org/rfc/rfc2369\n[RFC 8058]: https://www.rfc-editor.org/rfc/rfc8058\n[List-Unsubscribe header critical for sustained email delivery]: https://www.postmastery.com/list-unsubscribe-header-critical-for-sustained-email-delivery/\n[The Email Marketers Guide to Using List-Unsubscribe]: https://www.litmus.com/blog/the-ultimate-guide-to-list-unsubscribe/\n[List Unsubscribe Header in Email]: https://mailtrap.io/blog/list-unsubscribe-header/\n[Prevent mail to Gmail users from being blocked or sent to spam]: https://support.google.com/mail/answer/81126\n[Stack Overflow: Post parameter in path or in body]: https://stackoverflow.com/questions/42390564/post-parameter-in-path-or-in-body\n[How to Verify Email Address Without Sending an Email]: https://mailtrap.io/blog/verify-email-address-without-sending/\n[25, 2525, 465, 587, and Other Numbers: All About SMTP Ports]: https://mailtrap.io/blog/smtp-ports-25-465-587-used-for/\n[How to Choose the Right SMTP Port (Port 25, 587, 465, or 2525)]: https://kinsta.com/blog/smtp-port/\n[Which SMTP port should I use? Understanding ports 25, 465 \u0026 587]: https://www.mailgun.com/blog/email/which-smtp-port-understanding-ports-25-465-587/\n[Using curl to send email]: https://stackoverflow.com/questions/14722556/using-curl-to-send-email\n[Email Sender Reputation Made Simple]: https://mailtrap.io/blog/email-sender-reputation/\n[Why Go: Command-line Interfaces (CLIs)]: https://go.dev/solutions/clis\n[spf13/cobra]: https://github.com/spf13/cobra\n[AWS Lambda function logging in Go]: https://docs.aws.amazon.com/lambda/latest/dg/golang-logging.html\n[Working with stages for HTTP APIs]: https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-stages.html\n[AWS Lambda function errors in Go]: https://docs.aws.amazon.com/lambda/latest/dg/golang-exceptions.html\n[RFC 9110: HTTP Semantics]: https://www.rfc-editor.org/rfc/rfc9110.html\n[Golang Auto Build Versioning]: https://www.atatus.com/blog/golang-auto-build-versioning/\n[jasonmf/go-embed-version]: https://github.com/jasonmf/go-embed-version\n[Using ldflags to Set Version Information for Go Applications]: https://www.digitalocean.com/community/tutorials/using-ldflags-to-set-version-information-for-go-applications\n[go tool link]: https://pkg.go.dev/cmd/link\n[A better way than “ldflags” to add a build version to your Go binaries]: https://levelup.gitconnected.com/a-better-way-than-ldflags-to-add-a-build-version-to-your-go-binaries-2258ce419d2d\n[AWS Lambda function versions]: https://docs.aws.amazon.com/lambda/latest/dg/configuration-versions.html\n[Sending test emails in Amazon SES with the simulator]: https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html\n[Setting up event notification for Amazon SES]: https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications.html\n[Receiving Amazon SES notifications using Amazon SNS]: https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-sns.html\n[Contents of event data that Amazon SES publishes to Amazon SNS]: https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-contents.html\n[How email sending works in Amazon SES]: https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-process.html\n[Specifying a configuration set when you send email]: https://docs.aws.amazon.com/ses/latest/dg/using-configuration-sets-in-email.html\n[RFC 2782: A DNS RR for specifying the location of services (DNS SRV)]: https://www.rfc-editor.org/rfc/rfc2782.html\n[RFC 4409: Message Submission for Mail]: https://www.rfc-editor.org/rfc/rfc4409\n[RFC 6186: Use of SRV Records for Locating Email Submission/Access Services]: https://www.rfc-editor.org/rfc/rfc6186.html\n[MIME]: https://en.wikipedia.org/wiki/MIME\n[Does the presence of a Content-ID header in an email MIME mean that the attachment must be embedded?]: https://serverfault.com/a/489752\n[RFC 2183: Communicating Presentation Information in Internet Messages: The Content-Disposition Header Field]: https://www.rfc-editor.org/rfc/rfc2183\n[RFC 2392: Content-ID and Message-ID Uniform Resource Locators]: https://www.rfc-editor.org/rfc/rfc2392\n[The precise format of Content-Id header]: https://stackoverflow.com/questions/39577386/the-precise-format-of-content-id-header\n[RFC 7103: Advice for Safe Handling of Malformed Messages]: https://www.rfc-editor.org/rfc/rfc7103\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmbland%2Felistman","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmbland%2Felistman","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmbland%2Felistman/lists"}