├── .DS_Store ├── .dockerignore ├── .env.example ├── .github └── workflows │ ├── deploy_app.yml │ ├── e2e_cypress.yml │ ├── scripts_integration_test.yml │ └── scripts_unit_test.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── TODO.md ├── api ├── .dockerignore ├── .gitignore ├── package.json ├── serverless.yml ├── src │ ├── app.ts │ ├── index.ts │ ├── local.ts │ └── security │ │ └── verifyRecaptcha.ts └── tsconfig.json ├── business ├── jest.config.js ├── package.json ├── src │ ├── config │ │ ├── constants.ts │ │ ├── context.ts │ │ └── index.ts │ ├── index.ts │ ├── local │ │ └── setupDynamo.ts │ ├── notifications │ │ ├── index.ts │ │ ├── sendEmail.test.ts │ │ └── sendEmail.ts │ ├── persistence │ │ ├── dynamo.ts │ │ ├── getSubscriptionByEmail.ts │ │ ├── getSubscriptionById.ts │ │ ├── getSubscriptions.ts │ │ ├── index.ts │ │ ├── removeSubscription.ts │ │ └── saveSubscription.ts │ └── useCases │ │ ├── index.ts │ │ ├── removeBouncedEmailUseCase.ts │ │ ├── sendNewsletterUseCase.ts │ │ ├── subscribeUseCase.ts │ │ └── unsubscribeUseCase.ts └── tsconfig.json ├── data ├── emails │ ├── cookie-banner.mjml │ ├── cookie-banner.txt │ ├── feb5.mjml │ ├── feb5.txt │ ├── jan29.mjml │ ├── jan29.txt │ ├── rating-html-tips.mjml │ ├── rating-html-tips.txt │ ├── t3-course.mjml │ ├── t3-course.txt │ ├── welcome.mjml │ └── welcome.txt └── welcome.ts ├── docker-compose.yml ├── e2e ├── .gitignore ├── cypress.config.ts ├── cypress │ ├── e2e │ │ └── a_user_subscribes.cy.ts │ ├── fixtures │ │ └── example.json │ └── support │ │ ├── commands.ts │ │ └── e2e.ts ├── package.json └── tsconfig.json ├── infra ├── .DS_Store ├── .gitignore ├── .terraform-version ├── .terraform.lock.hcl ├── dynamo.tf ├── invalidate-cloudfront.sh ├── main.tf ├── ses.tf ├── site │ ├── error.html │ ├── index.html │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── state.tf ├── terraform.tfstate └── terraform.tfstate.backup ├── load-env.sh ├── package-lock.json ├── package.json ├── scripts ├── integration_tests │ └── sendEmailsCli.test.ts ├── jest.config.js ├── out.html ├── package.json ├── src │ ├── .gitignore │ ├── expectedHtml.html │ ├── import.ts │ ├── migration.ts │ ├── removeBouncedEmail.ts │ ├── removeBouncedEmailCli.ts │ ├── sendEmails.test.ts │ ├── sendEmails.ts │ ├── sendEmailsCli.ts │ └── util │ │ ├── getConfigs.ts │ │ └── verifyEnv.ts └── tsconfig.json ├── ui ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── cdk.context.json ├── cypress.config.ts ├── design │ └── design.webp ├── jest.config.js ├── next.config.mjs ├── package.json ├── policy.json ├── postcss.config.cjs ├── prettier.config.cjs ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── celebrating.svg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── site.webmanifest │ ├── success.png │ └── wdc.jpeg ├── src │ ├── api │ │ ├── useSubscribe.ts │ │ └── useUnsubscribe.ts │ ├── components │ │ ├── Alert.tsx │ │ ├── Button.tsx │ │ ├── Footer.tsx │ │ ├── Input.tsx │ │ ├── Label.tsx │ │ ├── NavBar.tsx │ │ └── icons │ │ │ ├── GlobeIcon.tsx │ │ │ ├── TwitchIcon.tsx │ │ │ ├── TwitterIcon.tsx │ │ │ └── YouTubeIcon.tsx │ ├── config │ │ └── constants.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── index.tsx │ │ ├── success.tsx │ │ └── unsubscribe │ │ │ └── [unsubscribeId].tsx │ ├── styles │ │ └── globals.css │ └── utils │ │ └── useReCaptcha.ts ├── sst-env.d.ts ├── sst.config.ts ├── tailwind.config.cjs ├── tsconfig.json └── tsconfig.spec.json ├── wait-for.sh └── yarn.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/.DS_Store -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/.dockerignore -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ACCESS_KEY=local 2 | SECRET_KEY=local 3 | REGION=us-east-1 4 | SES_ENDPOINT=http://localhost:9001 5 | DYNAMO_ENDPOINT=http://localhost:8000 6 | HOST_NAME=http://localhost:3000 7 | TABLE_NAME=webdevcody_newsletter 8 | NEXT_PUBLIC_API_URL=http://localhost:3010 9 | -------------------------------------------------------------------------------- /.github/workflows/deploy_app.yml: -------------------------------------------------------------------------------- 1 | name: Deploy It All 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | env: 8 | REGION: ${{ vars.REGION }} 9 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 10 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 11 | 12 | jobs: 13 | deploy_infra: 14 | runs-on: ubuntu-latest 15 | environment: 16 | name: production 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Run Docker Build 21 | run: | 22 | docker build --target base -t local . 23 | 24 | - name: Run terraform 25 | run: | 26 | docker run \ 27 | -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \ 28 | -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \ 29 | -v `pwd`:/home/app local sh \ 30 | -c "cd infra && terraform init && terraform apply -auto-approve" 31 | 32 | deploy_api: 33 | needs: deploy_infra 34 | runs-on: ubuntu-latest 35 | environment: 36 | name: production 37 | steps: 38 | - uses: actions/checkout@v3 39 | 40 | - name: Install modules 41 | run: yarn 42 | 43 | - name: Deploy api 44 | env: 45 | HOST_NAME: https://${{ vars.HOST_NAME }} 46 | DYNAMO_ENDPOINT: dynamodb.${{ vars.REGION }}.amazonaws.com 47 | SES_ENDPOINT: email.${{ vars.REGION }}.amazonaws.com 48 | TABLE_NAME: ${{ vars.TABLE_NAME }} 49 | ACCESS_KEY: ${{ secrets.ACCESS_KEY }} 50 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 51 | RECAPTCHA_SECRET: ${{ secrets.RECAPTCHA_SECRET }} 52 | run: yarn deploy:api 53 | 54 | deploy_ui: 55 | needs: deploy_infra 56 | runs-on: ubuntu-latest 57 | environment: 58 | name: production 59 | steps: 60 | - uses: actions/checkout@v3 61 | 62 | - name: Install modules 63 | run: yarn 64 | 65 | - name: Deploy ui 66 | env: 67 | NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${{ vars.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }} 68 | NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }} 69 | AWS_REGION: ${{ vars.REGION }} 70 | run: | 71 | yarn workspace @wdc-newsletter/ui deploy 72 | -------------------------------------------------------------------------------- /.github/workflows/e2e_cypress.yml: -------------------------------------------------------------------------------- 1 | name: E2E - Cypress 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Run Docker Compose 14 | env: 15 | NEXT_PUBLIC_API_URL: http://api:3010 16 | DISABLE_RECAPTCHA: true 17 | NEXT_PUBLIC_DISABLE_RECAPTCHA: true 18 | run: | 19 | docker-compose up -d --build 20 | 21 | - name: Run tests 22 | run: | 23 | docker exec shell sh -c "./wait-for.sh http://api:3010/status && ./wait-for.sh http://ui:3000 && CYPRESS_BASE_URL=http://ui:3000 yarn workspace @wdc-newsletter/e2e cypress" 24 | -------------------------------------------------------------------------------- /.github/workflows/scripts_integration_test.yml: -------------------------------------------------------------------------------- 1 | name: Scripts - Integration Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Run Docker Compose 14 | run: | 15 | docker-compose up -d --build 16 | 17 | - name: Run tests 18 | run: | 19 | docker exec shell sh -c "yarn workspace @wdc-newsletter/scripts test:integration" 20 | -------------------------------------------------------------------------------- /.github/workflows/scripts_unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Scripts - Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Install modules 14 | run: yarn 15 | 16 | - name: Run tests 17 | run: yarn workspace @wdc-newsletter/scripts test:unit 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env.prod 4 | /output 5 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": ["business", "ui"] 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 AS base 2 | 3 | RUN apt-get update -y && apt-get upgrade -y 4 | 5 | RUN apt-get install -y vim wget unzip curl 6 | 7 | RUN apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6 xauth xvfb 8 | 9 | WORKDIR /home/tmp 10 | 11 | RUN wget https://releases.hashicorp.com/terraform/1.3.7/terraform_1.3.7_linux_amd64.zip 12 | RUN unzip terraform_1.3.7_linux_amd64.zip 13 | RUN mv terraform /usr/local/bin/ 14 | 15 | WORKDIR /home/app 16 | 17 | FROM base AS deps 18 | 19 | COPY package.json . 20 | COPY business/package.json ./business/package.json 21 | COPY api/package.json ./api/package.json 22 | COPY ui/package.json ./ui/package.json 23 | COPY e2e/package.json ./e2e/package.json 24 | COPY scripts/package.json ./scripts/package.json 25 | 26 | RUN yarn -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | This is the newsletter application I use for my youtube channel to send out updates to my subscriber. 4 | 5 | ## User Features 6 | 7 | - a dedicated page where people can enter their email to subscribe to your newsletter 8 | - a success page which shows a custom message after subscribing 9 | - a dynamodb table which stores all subscriptions 10 | - a cli command for sending out mjml emails to your subscribers 11 | - users can unsubscribe via a rest api and link appended to all emails 12 | 13 | ## Code Features 14 | 15 | - fully deployable using IaC via terraform and serverless framework 16 | - api is a monolambda 17 | - all in typescript 18 | - yarn monorepo approach 19 | - e2e testing using cypress 20 | - integration and unit testing using jest 21 | - everything can be ran locally via docker-compose 22 | - ci/cd setup using github actions for testing and deployment 23 | 24 | ## How to Run 25 | 26 | 1. docker compose build 27 | 1. docker compose up 28 | 1. open http://localhost:3000 29 | 30 | ## How to Develop 31 | 32 | My goal is to get all of this project 100% running in docker for local development. This means you'd need to docker exec into the shell container to do any one off commands or scripts. It's a bit more overhead, but I think it's worth it for consistency across developers and machines. 33 | 34 | 1. `docker-compose up` 35 | 2. `docker exec -it shell /bin/bash` 36 | 37 | ## Running E2E Tests 38 | 39 | 1. `docker exec -it shell /bin/bash` 40 | 1. `yarn workspace @wdc-newsletter/e2e cypress` 41 | 42 | ## How to Send Emails 43 | 44 | 1. `. ./load-env.sh .env.prod` 45 | 1. `npx ts-node ./scripts/src/sendEmailsCli.ts "My New T3 Stack Course is Live" "./data/emails/t3-course.mjml"` 46 | 47 | ## Importing Emails to Prod 48 | 49 | 1. update .env to have prod info 50 | 2. create a `src/scripts/emails.json` with array of email address 51 | 3. run `npx ts-node src/scripts/import.ts` 52 | 53 | ## Deployment 54 | 55 | Create a Dynamodb table with the pk and sk named "pk" and "sk". Remember the name, you'll need it when setting up the user and policies. 56 | 57 | ### SES 58 | 59 | Setup SES for your domain and verify the identity. Keep track of this identity name since you'll need to update it in the `policy.json` 60 | 61 | #### Request SES Production Access 62 | 63 | Request production SES to get out of sandbox mode. You'll need to convince AWS you have a legit business reason to be sending emails. 64 | 65 | ### IAM User and Policy 66 | 67 | Create an IAM user for programmatic access and setup your keys inside your .env file. 68 | 69 | Modify the `policy.json` file and attach it to your user. 70 | 71 | #### ACM 72 | 73 | Create and validate a certficate for the following: 74 | 75 | - newsletter.webdevcody.com 76 | - newsletter-api.webdevcody.com 77 | 78 | #### Domain 79 | 80 | - Setup CNAME for api 81 | - Setup CNAME for ui 82 | 83 | #### Deploying the UI 84 | 85 | - setup and source .env by running `. ./load-env.sh` 86 | - yarn deploy:ui 87 | - make sure to add the certificate and alternate domain name to cloudfront distrubtion 88 | 89 | #### Creating Gateway Domain 90 | 91 | - yarn workspace @wdc-newsletter/api create-domain 92 | 93 | #### Deploying the API 94 | 95 | - setup and source .env by running `. ./load-env.sh` 96 | - yarn deploy:api 97 | 98 | # Issues 99 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Feat: 2 | 3 | - mobile friendly 4 | - sns + auto delete bounced subscriptions 5 | - know if user opened email 6 | - dashboard to view recently published newsletter 7 | 8 | Chore: 9 | 10 | - unit tests 11 | - verify the API is 100% IaC 12 | - verify the UI is 100% IaC 13 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /.serverless 3 | /.esbuild -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdc-newsletter/api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "dev": "nodemon src/local", 8 | "deploy": "serverless deploy", 9 | "create-domain": "serverless create_domain" 10 | }, 11 | "keywords": [], 12 | "author": "Cody Seibert", 13 | "license": "MIT", 14 | "dependencies": { 15 | "@wdc-newsletter/business": "*", 16 | "cors": "^2.8.5", 17 | "express": "^4.18.2", 18 | "express-winston": "^4.2.0", 19 | "node-fetch": "2.6.9", 20 | "superjson": "^1.12.2", 21 | "winston": "^3.8.2", 22 | "zod": "^3.20.2" 23 | }, 24 | "devDependencies": { 25 | "@types/aws-sdk": "^2.7.0", 26 | "@types/cors": "^2.8.13", 27 | "@types/express": "^4.17.16", 28 | "esbuild": "^0.16.17", 29 | "nodemon": "^2.0.20", 30 | "serverless": "^3.27.0", 31 | "serverless-domain-manager": "^6.2.2", 32 | "serverless-esbuild": "^1.37.3", 33 | "serverless-http": "^3.1.1", 34 | "serverless-plugin-monorepo": "^0.11.0", 35 | "ts-node": "^10.9.1", 36 | "typescript": "^4.9.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/serverless.yml: -------------------------------------------------------------------------------- 1 | # serverless.yml 2 | 3 | service: wdc-newsletter-api 4 | 5 | plugins: 6 | - serverless-plugin-monorepo 7 | - serverless-esbuild 8 | - serverless-domain-manager 9 | 10 | custom: 11 | esbuild: 12 | bundle: true 13 | minify: false 14 | 15 | customDomain: 16 | domainName: newsletter-api.webdevcody.com 17 | basePath: "" 18 | stage: ${self:provider.stage} 19 | certificateName: "newsletter-api.webdevcody.com" 20 | createRoute53Record: false 21 | endpointType: regional 22 | 23 | provider: 24 | name: aws 25 | runtime: nodejs18.x 26 | stage: prod 27 | region: us-east-1 28 | 29 | environment: 30 | ACCESS_KEY: ${env:ACCESS_KEY} 31 | SECRET_KEY: ${env:SECRET_KEY} 32 | REGION: ${env:REGION} 33 | SES_ENDPOINT: ${env:SES_ENDPOINT} 34 | DYNAMO_ENDPOINT: ${env:DYNAMO_ENDPOINT} 35 | HOST_NAME: ${env:HOST_NAME} 36 | TABLE_NAME: ${env:TABLE_NAME} 37 | RECAPTCHA_SECRET: ${env:RECAPTCHA_SECRET} 38 | 39 | functions: 40 | app: 41 | handler: src/index.handler 42 | events: 43 | - http: 44 | method: ANY 45 | path: /{proxy+} 46 | -------------------------------------------------------------------------------- /api/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import type { Request, Response } from "express"; 3 | import { 4 | getSubscriptionByEmailFactory, 5 | getSubscriptionByIdFactory, 6 | removeSubscriptionFactory, 7 | saveSubscriptionFactory, 8 | subscribeUseCase, 9 | unsubscribeUseCase, 10 | } from "@wdc-newsletter/business"; 11 | import cors from "cors"; 12 | import winston from "winston"; 13 | import expressWinston from "express-winston"; 14 | import { TDynamoConfig } from "@wdc-newsletter/business/src/persistence/dynamo"; 15 | import { verifyRecaptcha } from "./security/verifyRecaptcha"; 16 | 17 | export const app = express(); 18 | 19 | const dynamoConfig: TDynamoConfig = { 20 | region: process.env.REGION, 21 | accessKeyId: process.env.ACCESS_KEY, 22 | secretAccessKey: process.env.SECRET_KEY, 23 | endpoint: process.env.DYNAMO_ENDPOINT, 24 | }; 25 | 26 | app.use(express.json()); 27 | 28 | app.use( 29 | expressWinston.logger({ 30 | transports: [new winston.transports.Console()], 31 | format: winston.format.combine( 32 | winston.format.colorize(), 33 | winston.format.json() 34 | ), 35 | meta: false, 36 | msg: "HTTP ", 37 | expressFormat: true, 38 | colorize: false, 39 | ignoreRoute: function (req, res) { 40 | return false; 41 | }, 42 | }) 43 | ); 44 | 45 | app.use( 46 | cors({ 47 | origin: [ 48 | "https://newsletter.webdevcody.com", 49 | "https://webdevcody.com", 50 | "https://www.webdevcody.com", 51 | "http://localhost:3000", 52 | "http://ui:3000", 53 | ], 54 | }) 55 | ); 56 | 57 | app.get("/status", async function (req: Request, res: Response) { 58 | res.send("ok"); 59 | }); 60 | 61 | app.post("/subscriptions", async function (req: Request, res: Response) { 62 | const { token, email } = req.body; 63 | try { 64 | await verifyRecaptcha(token, process.env.RECAPTCHA_SECRET); 65 | } catch (err) { 66 | return res.status(400).send("recaptcha token failed to validate"); 67 | } 68 | 69 | await subscribeUseCase( 70 | { 71 | getSubscriptionByEmail: getSubscriptionByEmailFactory(dynamoConfig), 72 | saveSubscription: saveSubscriptionFactory(dynamoConfig), 73 | }, 74 | email 75 | ); 76 | res.send("subscribed"); 77 | }); 78 | 79 | app.delete( 80 | "/subscriptions/:unsubscribeId", 81 | async function (req: Request, res: Response) { 82 | const unsubscribeId = req.params.unsubscribeId; 83 | await unsubscribeUseCase( 84 | { 85 | getSubscriptionById: getSubscriptionByIdFactory(dynamoConfig), 86 | removeSubscription: removeSubscriptionFactory(dynamoConfig), 87 | }, 88 | unsubscribeId 89 | ); 90 | res.send("unsubscribed"); 91 | } 92 | ); 93 | -------------------------------------------------------------------------------- /api/src/index.ts: -------------------------------------------------------------------------------- 1 | import serverless from "serverless-http"; 2 | import { app } from "./app"; 3 | 4 | export const handler = serverless(app); 5 | -------------------------------------------------------------------------------- /api/src/local.ts: -------------------------------------------------------------------------------- 1 | import { app } from "./app"; 2 | 3 | app.listen(3010); 4 | -------------------------------------------------------------------------------- /api/src/security/verifyRecaptcha.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | export async function verifyRecaptcha(token: string, secret: string) { 4 | if (process.env.DISABLE_RECAPTCHA) return; 5 | 6 | const response = await fetch( 7 | "https://www.google.com/recaptcha/api/siteverify", 8 | { 9 | method: "POST", 10 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 11 | body: `secret=${secret}&response=${token}`, 12 | } 13 | ); 14 | const json = (await response.json()) as { success: boolean }; 15 | if (!json.success) { 16 | throw new Error("invalid recaptcha token"); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2021"], 4 | "module": "commonjs", 5 | "target": "ES2021", 6 | "rootDir": ".", 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src/**/*.ts"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /business/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testTimeout: 30000, 6 | }; 7 | -------------------------------------------------------------------------------- /business/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdc-newsletter/business", 3 | "version": "1.0.0", 4 | "description": "all business related logic for the newsletter", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "test": "jest", 8 | "dynamo:setup": "tsx src/local/setupDynamo.ts" 9 | }, 10 | "keywords": [], 11 | "author": "Cody Seibert", 12 | "license": "MIT", 13 | "dependencies": { 14 | "aws-sdk": "^2.1306.0", 15 | "throttled-queue": "^2.1.4", 16 | "uuid": "^9.0.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^18.11.18", 20 | "@types/uuid": "^9.0.0", 21 | "ts-node": "^10.9.1", 22 | "tsx": "^4.7.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /business/src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | NODE_ENV: process.env.NODE_ENV, 3 | ACCESS_KEY: process.env.ACCESS_KEY!, 4 | SECRET_KEY: process.env.SECRET_KEY!, 5 | REGION: process.env.REGION!, 6 | HOST_NAME: process.env.HOST_NAME!, 7 | SES_ENDPOINT: process.env.SES_ENDPOINT!, 8 | DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT!, 9 | TABLE_NAME: process.env.TABLE_NAME || "webdevcody_newsletter", 10 | }; 11 | -------------------------------------------------------------------------------- /business/src/config/context.ts: -------------------------------------------------------------------------------- 1 | import { env } from "./constants"; 2 | 3 | export type TContext = { 4 | env: typeof env; 5 | }; 6 | -------------------------------------------------------------------------------- /business/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./constants"; 2 | -------------------------------------------------------------------------------- /business/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCases"; 2 | export * from "./persistence"; 3 | export * from "./notifications"; 4 | export * from "./config"; 5 | -------------------------------------------------------------------------------- /business/src/local/setupDynamo.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { DynamoDB } from "aws-sdk"; 3 | import { env } from "../config/constants"; 4 | 5 | const dynamo = new DynamoDB({ 6 | region: process.env.REGION, 7 | credentials: { 8 | accessKeyId: process.env.ACCESS_KEY!, 9 | secretAccessKey: process.env.SECRET_KEY!, 10 | }, 11 | endpoint: process.env.DYNAMO_ENDPOINT!, 12 | }); 13 | 14 | async function main() { 15 | console.info("creating table"); 16 | await dynamo 17 | .createTable({ 18 | TableName: env.TABLE_NAME, 19 | KeySchema: [ 20 | { 21 | AttributeName: "pk", 22 | KeyType: "HASH", 23 | }, 24 | { 25 | AttributeName: "sk", 26 | KeyType: "RANGE", 27 | }, 28 | ], 29 | AttributeDefinitions: [ 30 | { 31 | AttributeName: "pk", 32 | AttributeType: "S", 33 | }, 34 | { 35 | AttributeName: "sk", 36 | AttributeType: "S", 37 | }, 38 | { 39 | AttributeName: "unsubscribeId", 40 | AttributeType: "S", 41 | }, 42 | ], 43 | ProvisionedThroughput: { 44 | ReadCapacityUnits: 10, 45 | WriteCapacityUnits: 10, 46 | }, 47 | GlobalSecondaryIndexes: [ 48 | { 49 | IndexName: "gsi1", 50 | KeySchema: [ 51 | { 52 | AttributeName: "unsubscribeId", 53 | KeyType: "HASH", 54 | }, 55 | { 56 | AttributeName: "pk", 57 | KeyType: "RANGE", 58 | }, 59 | ], 60 | Projection: { 61 | ProjectionType: "ALL", 62 | }, 63 | ProvisionedThroughput: { 64 | ReadCapacityUnits: 5, 65 | WriteCapacityUnits: 5, 66 | }, 67 | }, 68 | ], 69 | }) 70 | .promise() 71 | .catch(async (err) => { 72 | console.error(err); 73 | }); 74 | 75 | console.info("done creating table"); 76 | } 77 | 78 | main(); 79 | -------------------------------------------------------------------------------- /business/src/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./sendEmail"; 2 | -------------------------------------------------------------------------------- /business/src/notifications/sendEmail.test.ts: -------------------------------------------------------------------------------- 1 | import { sendEmailFactory } from "./sendEmail"; 2 | import { SES } from "aws-sdk"; 3 | import { env } from "../config/constants"; 4 | 5 | jest.mock("../config/constants", () => { 6 | return { 7 | env: { 8 | HOST_NAME: "https://newsletter.webdevcody.com", 9 | }, 10 | }; 11 | }); 12 | 13 | jest.mock("aws-sdk", () => { 14 | const mockSES = { 15 | sendEmail: jest.fn().mockReturnThis(), 16 | promise: jest.fn(), 17 | }; 18 | const mockSESConstructor = jest.fn(() => mockSES); 19 | 20 | return { 21 | SES: mockSESConstructor, 22 | }; 23 | }); 24 | 25 | describe("sendEmail", () => { 26 | it("should send an email", async () => { 27 | const sendEmail = sendEmailFactory({} as any); 28 | const mockSES = new SES(); 29 | (mockSES.sendEmail().promise as any).mockResolvedValue({ 30 | MessageId: "1234", 31 | }); 32 | 33 | await sendEmail({ 34 | email: "hello@example.com", 35 | htmlBody: "this is a test html", 36 | subject: "welcome!", 37 | textBody: "this is a test text", 38 | unsubscribeId: "abc-123", 39 | }); 40 | 41 | expect(mockSES.sendEmail).toHaveBeenCalledWith({ 42 | Destination: { 43 | ToAddresses: ["hello@example.com"], 44 | }, 45 | Message: { 46 | Body: { 47 | Html: { 48 | Charset: "UTF-8", 49 | Data: `this is a test html
Seibert Software Solutions, LLC
PO Box 913
Harrison TN, 37341

Unsubscribe
`, 50 | }, 51 | Text: { 52 | Charset: "UTF-8", 53 | Data: `this is a test textSeibert Software Solutions, LLC @ PO Box 913, Harrison TN, 37341, You can unsubscribe here: ${env.HOST_NAME}/unsubscribe/abc-123`, 54 | }, 55 | }, 56 | Subject: { 57 | Charset: "UTF-8", 58 | Data: "welcome!", 59 | }, 60 | }, 61 | ReturnPath: "webdevcody@gmail.com", 62 | Source: "WebDevCody Newsletter ", 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /business/src/notifications/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import { SES } from "aws-sdk"; 2 | import throttledQueue from "throttled-queue"; 3 | import { env } from "../config/constants"; 4 | 5 | export type TSesConfig = { 6 | region: string; 7 | accessKeyId: string; 8 | secretAccessKey: string; 9 | endpoint: string; 10 | }; 11 | 12 | function getSesClient(config: TSesConfig) { 13 | return new SES({ 14 | region: config.region, 15 | credentials: { 16 | accessKeyId: config.accessKeyId, 17 | secretAccessKey: config.secretAccessKey, 18 | }, 19 | endpoint: config.endpoint, 20 | }); 21 | } 22 | 23 | const throttle = throttledQueue(5, 1000, true); 24 | 25 | export type TSendEmail = ReturnType; 26 | 27 | export function sendEmailFactory(config: TSesConfig) { 28 | return ({ 29 | email, 30 | htmlBody, 31 | subject, 32 | textBody, 33 | unsubscribeId, 34 | }: { 35 | email: string; 36 | htmlBody: string; 37 | subject: string; 38 | textBody: string; 39 | unsubscribeId: string; 40 | }) => { 41 | const unsubscribeLinkHtml = `
Seibert Software Solutions, LLC
PO Box 913
Harrison TN, 37341

Unsubscribe
`; 42 | const unsubscribeTextHtml = `Seibert Software Solutions, LLC @ PO Box 913, Harrison TN, 37341, You can unsubscribe here: ${env.HOST_NAME}/unsubscribe/${unsubscribeId}`; 43 | 44 | return throttle(() => { 45 | console.info(`sending email to ${email}`); 46 | return getSesClient(config) 47 | .sendEmail({ 48 | Destination: { 49 | ToAddresses: [email], 50 | }, 51 | Message: { 52 | Body: { 53 | Html: { 54 | Charset: "UTF-8", 55 | Data: htmlBody + unsubscribeLinkHtml, 56 | }, 57 | Text: { 58 | Charset: "UTF-8", 59 | Data: textBody + unsubscribeTextHtml, 60 | }, 61 | }, 62 | Subject: { 63 | Charset: "UTF-8", 64 | Data: subject, 65 | }, 66 | }, 67 | ReturnPath: "webdevcody@gmail.com", 68 | Source: "WebDevCody Newsletter ", 69 | }) 70 | .promise() 71 | .catch((err) => { 72 | // TODO: on error, delete the bad email 73 | console.error(err); 74 | throw err; 75 | }); 76 | }); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /business/src/persistence/dynamo.ts: -------------------------------------------------------------------------------- 1 | import { DynamoDB } from "aws-sdk"; 2 | import { env } from "../config/constants"; 3 | 4 | export type TDynamoConfig = { 5 | region: string; 6 | accessKeyId: string; 7 | secretAccessKey: string; 8 | endpoint: string; 9 | }; 10 | 11 | function getClient({ 12 | region, 13 | accessKeyId, 14 | secretAccessKey, 15 | endpoint, 16 | }: TDynamoConfig) { 17 | return new DynamoDB.DocumentClient({ 18 | region, 19 | credentials: { 20 | accessKeyId, 21 | secretAccessKey, 22 | }, 23 | endpoint, 24 | }); 25 | } 26 | 27 | export function get(config: TDynamoConfig, key: { pk: string; sk: string }) { 28 | return getClient(config) 29 | .get({ 30 | TableName: env.TABLE_NAME, 31 | Key: key, 32 | }) 33 | .promise() 34 | .then(({ Item }) => Item); 35 | } 36 | 37 | export function remove(config: TDynamoConfig, key: { pk: string; sk: string }) { 38 | return getClient(config) 39 | .delete({ 40 | TableName: env.TABLE_NAME, 41 | Key: key, 42 | }) 43 | .promise(); 44 | } 45 | 46 | export function scan(config: TDynamoConfig) { 47 | return getClient(config) 48 | .scan({ 49 | TableName: env.TABLE_NAME, 50 | }) 51 | .promise() 52 | .then(({ Items }) => Items ?? []) as Promise<{ pk: string; sk: string }[]>; 53 | } 54 | 55 | export function put( 56 | config: TDynamoConfig, 57 | item: { 58 | pk: string; 59 | sk: string; 60 | [key: string]: string | number | boolean; 61 | } 62 | ) { 63 | return getClient(config) 64 | .put({ 65 | TableName: env.TABLE_NAME, 66 | Item: item, 67 | }) 68 | .promise(); 69 | } 70 | 71 | export function queryFirstByGSI1(config: TDynamoConfig, pk: string) { 72 | return getClient(config) 73 | .query({ 74 | TableName: env.TABLE_NAME, 75 | IndexName: "gsi1", 76 | KeyConditionExpression: "#unsubscribeId = :pk", 77 | ExpressionAttributeValues: { 78 | ":pk": pk, 79 | }, 80 | ExpressionAttributeNames: { 81 | "#unsubscribeId": "unsubscribeId", 82 | }, 83 | Limit: 1, 84 | }) 85 | .promise() 86 | .then((results) => results.Items?.[0]); 87 | } 88 | -------------------------------------------------------------------------------- /business/src/persistence/getSubscriptionByEmail.ts: -------------------------------------------------------------------------------- 1 | import { get, TDynamoConfig } from "./dynamo"; 2 | import { TSubscription } from "./getSubscriptionById"; 3 | 4 | export type TGetSubscriptionByEmail = ReturnType< 5 | typeof getSubscriptionByEmailFactory 6 | >; 7 | 8 | export function getSubscriptionByEmailFactory(config: TDynamoConfig) { 9 | return (email: string) => 10 | get(config, { 11 | pk: `email|${email}`, 12 | sk: `email|${email}`, 13 | }).then((subscription) => subscription as TSubscription); 14 | } 15 | -------------------------------------------------------------------------------- /business/src/persistence/getSubscriptionById.ts: -------------------------------------------------------------------------------- 1 | import { queryFirstByGSI1, TDynamoConfig } from "./dynamo"; 2 | 3 | export type TSubscription = { 4 | pk: string; 5 | sk: string; 6 | unsubscribeId: string; 7 | email: string; 8 | }; 9 | 10 | export type TGetSubscriptionById = ReturnType< 11 | typeof getSubscriptionByIdFactory 12 | >; 13 | 14 | export function getSubscriptionByIdFactory(config: TDynamoConfig) { 15 | return (unsubscribeId: string) => 16 | queryFirstByGSI1(config, unsubscribeId).then( 17 | (subscription) => subscription as TSubscription 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /business/src/persistence/getSubscriptions.ts: -------------------------------------------------------------------------------- 1 | import { type TSubscription } from "./getSubscriptionById"; 2 | import { scan, TDynamoConfig } from "./dynamo"; 3 | 4 | export type TGetSubscriptions = ReturnType; 5 | 6 | export function getSubscriptionsFactory(config: TDynamoConfig) { 7 | return async function () { 8 | const subscriptions = await scan(config); 9 | const filteredSubscriptions = subscriptions.filter((item) => 10 | item.pk.startsWith("email|") 11 | ); 12 | return filteredSubscriptions as TSubscription[]; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /business/src/persistence/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getSubscriptionByEmail"; 2 | export * from "./getSubscriptionById"; 3 | export * from "./getSubscriptions"; 4 | export * from "./removeSubscription"; 5 | export * from "./saveSubscription"; 6 | -------------------------------------------------------------------------------- /business/src/persistence/removeSubscription.ts: -------------------------------------------------------------------------------- 1 | import { type TSubscription } from "./getSubscriptionById"; 2 | import { remove, TDynamoConfig } from "./dynamo"; 3 | 4 | export type TRemoveSubscription = ReturnType; 5 | 6 | export function removeSubscriptionFactory(config: TDynamoConfig) { 7 | return async (subscription: TSubscription) => { 8 | Promise.all([ 9 | remove(config, { 10 | pk: `email|${subscription.email}`, 11 | sk: `email|${subscription.email}`, 12 | }), 13 | ]); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /business/src/persistence/saveSubscription.ts: -------------------------------------------------------------------------------- 1 | import { put, TDynamoConfig } from "./dynamo"; 2 | 3 | export type TSaveSubscription = ReturnType; 4 | 5 | export function saveSubscriptionFactory(config: TDynamoConfig) { 6 | return async (email: string, unsubscribeId: string) => { 7 | await Promise.all([ 8 | put(config, { 9 | pk: `email|${email}`, 10 | sk: `email|${email}`, 11 | email, 12 | unsubscribeId, 13 | createdAt: new Date().toISOString(), 14 | }), 15 | ]); 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /business/src/useCases/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./sendNewsletterUseCase"; 2 | export * from "./subscribeUseCase"; 3 | export * from "./unsubscribeUseCase"; 4 | export * from "./removeBouncedEmailUseCase"; 5 | -------------------------------------------------------------------------------- /business/src/useCases/removeBouncedEmailUseCase.ts: -------------------------------------------------------------------------------- 1 | import { TGetSubscriptionByEmail } from "../persistence"; 2 | import { TRemoveSubscription } from "../persistence/removeSubscription"; 3 | 4 | export type TRemoveBouncedEmailUseCase = typeof removeBouncedEmailUseCase; 5 | 6 | export async function removeBouncedEmailUseCase( 7 | { 8 | getSubscriptionByEmail, 9 | removeSubscription, 10 | }: { 11 | getSubscriptionByEmail: TGetSubscriptionByEmail; 12 | removeSubscription: TRemoveSubscription; 13 | }, 14 | email: string 15 | ) { 16 | const subscription = await getSubscriptionByEmail(email); 17 | 18 | if (!subscription) { 19 | return { 20 | message: "success", 21 | }; 22 | } 23 | 24 | await removeSubscription(subscription); 25 | 26 | return { 27 | message: "success", 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /business/src/useCases/sendNewsletterUseCase.ts: -------------------------------------------------------------------------------- 1 | import { TGetSubscriptions } from "../persistence/getSubscriptions"; 2 | import { TSendEmail } from "../notifications/sendEmail"; 3 | 4 | export type TSendNewsletterUseCase = typeof sendNewsletterUseCase; 5 | 6 | export async function sendNewsletterUseCase( 7 | { 8 | getSubscriptions, 9 | sendEmail, 10 | }: { 11 | getSubscriptions: TGetSubscriptions; 12 | sendEmail: TSendEmail; 13 | }, 14 | { 15 | subject, 16 | body, 17 | text, 18 | }: { 19 | subject: string; 20 | body: string; 21 | text: string; 22 | } 23 | ) { 24 | const subscriptions = await getSubscriptions(); 25 | 26 | console.log(`preparing to send ${subscriptions.length} emails`); 27 | 28 | await Promise.allSettled( 29 | subscriptions.map((subscription) => { 30 | return sendEmail({ 31 | email: subscription.email, 32 | htmlBody: body, 33 | textBody: text, 34 | unsubscribeId: subscription.unsubscribeId, 35 | subject, 36 | }); 37 | }) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /business/src/useCases/subscribeUseCase.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import { TSendEmail } from "../notifications"; 3 | import { TGetSubscriptionByEmail } from "../persistence/getSubscriptionByEmail"; 4 | import { TSaveSubscription } from "../persistence/saveSubscription"; 5 | 6 | export async function subscribeUseCase( 7 | { 8 | getSubscriptionByEmail, 9 | saveSubscription, 10 | }: { 11 | getSubscriptionByEmail: TGetSubscriptionByEmail; 12 | saveSubscription: TSaveSubscription; 13 | }, 14 | email: string 15 | ) { 16 | const subscription = await getSubscriptionByEmail(email); 17 | 18 | if (subscription) { 19 | return { 20 | message: "success", 21 | }; 22 | } 23 | 24 | const unsubscribeId = uuidv4(); 25 | 26 | await saveSubscription(email, unsubscribeId); 27 | 28 | return { 29 | message: "success", 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /business/src/useCases/unsubscribeUseCase.ts: -------------------------------------------------------------------------------- 1 | import { TGetSubscriptionById } from "../persistence"; 2 | import { TRemoveSubscription } from "../persistence/removeSubscription"; 3 | 4 | export async function unsubscribeUseCase( 5 | { 6 | getSubscriptionById, 7 | removeSubscription, 8 | }: { 9 | getSubscriptionById: TGetSubscriptionById; 10 | removeSubscription: TRemoveSubscription; 11 | }, 12 | unsubscribeId: string 13 | ) { 14 | const subscription = await getSubscriptionById(unsubscribeId); 15 | 16 | if (!subscription) { 17 | return { 18 | message: "success", 19 | }; 20 | } 21 | 22 | await removeSubscription(subscription); 23 | 24 | return { 25 | message: "success", 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /business/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // these options are overrides used only by ts-node 4 | // same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable 5 | "compilerOptions": { 6 | "module": "commonjs" 7 | } 8 | }, 9 | "compilerOptions": { 10 | "lib": ["ES2021"], 11 | "module": "commonjs", 12 | "target": "ES2021", 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noEmit": true, 18 | "esModuleInterop": true, 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "incremental": true, 23 | "noUncheckedIndexedAccess": true 24 | }, 25 | "include": ["src/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /data/emails/cookie-banner.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 |

Why do sites have Cookie Banners?

20 |

21 | If you have browsed around enough on the internet, you'll see that 22 | many sites have a banner that pops up explaining the use of cookies 23 | on their site. It seems annoying, but did you know that there are 24 | laws in the EU trying to protect citizens from having their 25 | information gathered without their consent? 26 |

27 | 28 |

29 | I've always worked on internal applications for companies or sites 30 | used only by the US, which means I never really took the time to 31 | understand the purpose of these banners. But since I've recently 32 | been working on my 33 | https://icongeneratorai.com 36 | SaaS product, and I've had many users from the EU try to generate 37 | icons, I decided it was time to add in a cookie banner. 38 |

39 | 40 |

When do you need a cookie banner?

41 | 42 |

43 | If your application uses cookies to track a user, such as using 44 | google analytics, you will need a cookie banner. You should never 45 | inject the google analytics scripts without the user's consent, 46 | which means your "accept all cookies" button should do that google 47 | analytics setup. If the user denies those cookies, you should never 48 | inject those scripts either. You also need to provide a page that 49 | allows users to change their preferences at any time and also a way 50 | for them to clear those cookies from their browser when they decide 51 | they don't want to be tracked. 52 |

53 | 54 |

How can you easily implement a cookie banner?

55 | 56 |

57 | Recently I found a react library which I imported into my _app.tsx 58 | file in next.js called 59 | https://www.npmjs.com/package/react-cookie-consent. It works pretty well, but honestly if you wanted to build your 64 | own banner it isn't too hard to do. In the banner, you'll want to 65 | make sure the text explains why exactly you are using cookies on 66 | this site and how you may be tracking their information. You will 67 | want a button for them to accept your cookies, and a button for them 68 | to prevent the tracking cookies. Note that authentication-related 69 | cookies are unrelated to this banner. If your site doesn't track 70 | anyone, you don't need a banner because those authentication cookies 71 | are essential for the site to work. 72 |

73 | 74 |

75 | I hope you found this little snippet of information useful. Remember 76 | to abide by the laws of your country, and be courteous to your 77 | users; don't just track them without giving them consent. 78 |

79 | 80 |

Have a good day, and happy coding!

81 |
82 |
83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /data/emails/cookie-banner.txt: -------------------------------------------------------------------------------- 1 | Why do sites have Cookie Banners? 2 | If you have browsed around enough on the internet, you'll see that many sites have a banner that pops up explaining the use of cookies on their site. It seems annoying, but did you know that there are laws in the EU trying to protect citizens from having their information gathered without their consent? 3 | 4 | I've always worked on internal applications for companies or sites used only by the US, which means I never really took the time to understand the purpose of these banners. But since I've recently been working on my https://icongeneratorai.com SaaS product, and I've had many users from the EU try to generate icons, I decided it was time to add a cookie banner. 5 | 6 | When do you need a cookie banner? 7 | If your application uses cookies to track a user, such as using google analytics, you will need a cookie banner. You should never inject the google analytics scripts without the user's consent, which means your "accept all cookies" button should do that google analytics setup. If the user denies those cookies, you should never inject those scripts either. You also need to provide a page that allows users to change their preferences at any time and also a way for them to clear those cookies from their browser when they decide they don't want to be tracked. 8 | 9 | How can you easily implement a cookie banner? 10 | Recently I found a react library which I imported into my _app.tsx file in next.js called https://www.npmjs.com/package/react-cookie-consent. It works pretty well, but honestly, if you wanted to build your own banner it isn't too hard to do. In the banner, you'll want to make sure the text explains why exactly you are using cookies on this site and how you may be tracking their information. You will want a button for them to accept your cookies, and a button for them to prevent the tracking cookies. Note that authentication-related cookies are unrelated to this banner. If your site doesn't track anyone, you don't need a banner because those authentication cookies are essential for the site to work. 11 | 12 | I hope you found this little snippet of information useful. Remember to abide by the laws of your country, and be courteous to your users; don't just track them without giving them consent. 13 | 14 | Have a good day and happy coding! 15 | 16 | -------------------------------------------------------------------------------- /data/emails/feb5.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 |

4 Great Node CLI Ideas to Practice Coding

20 |

21 | Many beginners are hyper-focused on learning react and building out 22 | user interfaces, but trying to build our command line tools is an 23 | alternative approach to solidify your knowledge with javascript, 24 | node, and asynchronous programming. CLI tools might sound simple, 25 | but they can easily get complex as well. You need to read from the 26 | user input using command line arguments, you need to run complex 27 | logic using that initial input, and you often ended to write to the 28 | disk or make API requests. By taking the focus away from the UI, you 29 | can focus more on the fundamentals of coding. 30 |

31 | 32 |

33 | I wanted to provide you with four intermediate CLI project ideas you 34 | can try building for yourself. I promise to try to build these tools 35 | yourself will make you an overall better problem solver and 36 | javascript developer, and the things learned WILL transfer over to 37 | the front-end side of web development. 38 |

39 | 40 |

Converting a CSV file to JSON

41 | 42 |

43 | Doing file reading and writing is critical for all programming 44 | languages. Getting better at using node’s standard library for 45 | parsing files will help you understand asynchronous code and maybe 46 | even a little bit more about content types such as utf-8 or binary 47 | you’ll need to understand when writing and reading files. Next, 48 | you’ll need to figure out how to parse the CSV file after you read 49 | it, which you could do by googling around for an existing npm 50 | library or you could try to really stress your fundamental 51 | javascript knowledge by looping over every line in the CSV, 52 | splitting it by comma, and converting it to JSON objects. Finally, 53 | you’ll want to track all of those rows inside an array so that you 54 | can JSON.stringify the array out to a .json file. A lot to learn 55 | from a simple CLI tool. Add in support for command line arguments if 56 | you’re feeling adventurous. 57 |

58 | 59 |

CLI Email Sender Tool

60 | 61 |

62 | Write a CLI tool that takes in an email address, a subject, and your 63 | email body command line arguments. You could also have the email 64 | addresses or email content come from a file if you are feeling 65 | adventurous. The goal is to write a tool that uses a tool such as 66 | nodemailer to use your Gmail account to send out an email. Learning 67 | this will help you understand how to use third-party npm 68 | dependencies, how to read their documentation, how to use command 69 | line arguments, and a little bit more about how emails work, and 70 | it’s something you can at some point add to the business logic of 71 | your API when people register or forget their password. 72 |

73 | 74 |

CLI Hangman Game

75 | 76 |

77 | This is a fun project for beginners because it stresses your 78 | knowledge of how you can continuously read and wait for command line 79 | input from the user. You’ll also have to learn how to use the 80 | console log to print out designs and guessed letters to the console. 81 | You’ll need to keep track of which letters the user guessed and 82 | which ones they got correct. You can also take it to the next level 83 | by connecting to an API to fetch a random word that the user will 84 | have to guess. A simple game like this is great practice for various 85 | fundamental things you’ll encounter in the real world like arrays, 86 | looping, promises, API requests, console logging, etc. 87 |

88 | 89 |

Image Scaling CLI Tool

90 | 91 |

92 | This is a CLI tool that will take in a path to an image file and 93 | scale it down to various smaller device sizes which will allow you 94 | to make more performant websites. Some tools do this for us already, 95 | but learning how to make one yourself using graphicsmagick to load 96 | in images and scale them is good practice with learning how to use 97 | third-party machine dependencies you’d need to set up for node to 98 | invoke. To practice your node sdk skills, use the node’s exec_child 99 | commands to spawn up child processes that can invoke other command 100 | line tools installed on the machine. You’ll also need to learn how 101 | to write these images to the disk. 102 |

103 | 104 |

Conclusion

105 |

106 | I hope you try to implement these ideas because I do feel they are 107 | great practice. Stay in touch because I plan to make a small paid 108 | course for maybe $2-3 that might build some of these tools and walk 109 | you through how I’d do it. 110 |

111 | 112 |

Have a good day, and happy coding!

113 |
114 |
115 |
116 |
117 |
118 | -------------------------------------------------------------------------------- /data/emails/feb5.txt: -------------------------------------------------------------------------------- 1 | 4 Great Node CLI Ideas to Practice Coding 2 | 3 | Many beginners are hyper-focused on learning react and building out user interfaces, but trying to build our command line tools is an alternative approach to solidify your knowledge with javascript, node, and asynchronous programming. CLI tools might sound simple, but they can easily get complex as well. You need to read from the user input using command line arguments, you need to run complex logic using that initial input, and you often ended to write to the disk or make API requests. By taking the focus away from the UI, you can focus more on the fundamentals of coding. 4 | 5 | I wanted to provide you with four intermediate CLI project ideas you can try building for yourself. I promise to try to build these tools yourself will make you an overall better problem solver and javascript developer, and the things learned WILL transfer over to the front-end side of web development. 6 | 7 | Converting a CSV file to JSON 8 | 9 | Doing file reading and writing is critical for all programming languages. Getting better at using node’s standard library for parsing files will help you understand asynchronous code and maybe even a little bit more about content types such as utf-8 or binary you’ll need to understand when writing and reading files. Next, you’ll need to figure out how to parse the CSV file after you read it, which you could do by googling around for an existing npm library or you could try to really stress your fundamental javascript knowledge by looping over every line in the CSV, splitting it by comma, and converting it to JSON objects. Finally, you’ll want to track all of those rows inside an array so that you can JSON.stringify the array out to a .json file. A lot to learn from a simple CLI tool. Add in support for command line arguments if you’re feeling adventurous. 10 | 11 | 12 | CLI Email Sender Tool 13 | 14 | Write a CLI tool that takes in an email address, a subject, and your email body command line arguments. You could also have the email addresses or email content come from a file if you are feeling adventurous. The goal is to write a tool that uses a tool such as nodemailer to use your Gmail account to send out an email. Learning this will help you understand how to use third-party npm dependencies, how to read their documentation, how to use command line arguments, and a little bit more about how emails work, and it’s something you can at some point add to the business logic of your API when people register or forget their password. 15 | 16 | CLI Hangman Game 17 | 18 | This is a fun project for beginners because it stresses your knowledge of how you can continuously read and wait for command line input from the user. You’ll also have to learn how to use the console log to print out designs and guessed letters to the console. You’ll need to keep track of which letters the user guessed and which ones they got correct. You can also take it to the next level by connecting to an API to fetch a random word that the user will have to guess. A simple game like this is great practice for various fundamental things you’ll encounter in the real world like arrays, looping, promises, API requests, console logging, etc. 19 | 20 | Image Scaling CLI Tool 21 | 22 | This is a CLI tool that will take in a path to an image file and scale it down to various smaller device sizes which will allow you to make more performant websites. Some tools do this for us already, but learning how to make one yourself using graphicsmagick to load in images and scale them is good practice with learning how to use third-party machine dependencies you’d need to set up for node to invoke. To practice your node sdk skills, use the node’s exec_child commands to spawn up child processes that can invoke other command line tools installed on the machine. You’ll also need to learn how to write these images to the disk. 23 | 24 | 25 | I hope you try to implement these ideas because I do feel they are great practice. Stay in touch because I plan to make a small paid course for maybe $2-3 that might build some of these tools and walk you through how I’d do it. 26 | 27 | Have a good day, and happy coding! 28 | -------------------------------------------------------------------------------- /data/emails/jan29.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 |

The First Email

20 |

21 | Since this is my first newsletter email, I want to start by saying 22 | thank you all so much for subscribing to my channel. I'm happy to 23 | know that my content has not only helped many of you learn to code 24 | but also some have said they have landed internships and jobs from 25 | watching my videos. 26 |

27 | 28 |

29 | So what is the purpose of this newsletter? To be honest, I don't 30 | know yet, but I plan to hopefully use it as another way to send you 31 | all updates on my channel, links to useful resources, maybe links to 32 | exclusive videos I may record, and also maybe some little tips and 33 | tricks related to coding or the tech stack I'm currently using 34 | (typescript, tailwind, next, react, etc). 35 |

36 |

37 | If you don't find this content useful, you can unsubscribe using the 38 | link at the bottom of this email, but I would ask you to give me 39 | some feedback if you think of ways I could provide more value to 40 | y'all using this newsletter. 41 |

42 | 43 |

What have I been up to?

44 | 45 |

46 | This week I've been focusing on building up my brand and trying to 47 | put more effort into marketing my channel. I set up a 48 | twitch channel 51 | channel which I've been multicast streaming to when I do my normal 52 | youtube streams, so be sure to follow if you like watching streams 53 | on twitch. 54 |

55 | 56 |

57 | As you already know, I recently changed my channel brand from Web 58 | Dev Junkie to Web Dev Cody. The main reason was I wanted to get my 59 | name in my channel and decided to do it before I got to 100k 60 | subscribers. Also, the word Junkie doesn't have the best connotation 61 | and isn't that respectful for people will real drug addiction 62 | issues. 63 |

64 | 65 |

66 | Another approach I'm taking to growing my brand is working on a 67 | personal website which I may use as a portfolio and maybe adding a 68 | blog where I can also publish some of these newsletter updates if 69 | that might be interesting to read for y'all. 70 |

71 | 72 |

My personal site using Astro

73 | 74 |

75 | I've been hearing some good things about Astro, so I decided to set 76 | up a personal website using Astro. I will be honest, I only created 77 | a single page with the Astro site, but overall I think this 78 | framework is easier to work with compared to Next.js. Don't get me 79 | wrong, next.js is a great framework, but it can be a little 80 | confusing to learn at first. If you're looking into a simple way to 81 | create statically generated sites, Astro might be your solution. It 82 | has the power to create minimal webpages that contain zero 83 | javascript unless you manually add in interactivity. 84 |

85 | 86 |

87 | I also decided to deploy this site using AWS Amplify. Amplify is 88 | another piece of technology I hear a lot about, and I use many AWS 89 | services at work so I figured I'd give it a try. Amplify isn't as 90 | user-friendly as something like vercel or railway, but it will build 91 | and deploy your next application to the CloudFront CDN which means 92 | your static files will load fast for your users. It's also really 93 | cheap to deploy using amplify. 94 |

95 | 96 |

97 | You can watch 98 | 102 | my video 103 | 104 | of me talking about how I hosted my astro site using AWS amplify the 105 | following video 106 | 107 | 111 | 116 | 117 |

118 | 119 |

Making my newsletter production ready

120 | 121 |

122 | I published two live streams this week where I worked on adding some 123 | integration and e2e cypress tests on my newsletter application. 124 |

125 | 126 |

127 | Learning how to write tests is critical for building quality 128 | software applications. When you're on a large team of engineers who 129 | are committing code multiple times daily, it is very easy to 130 | accidentally break a feature for your users. Some projects may have 131 | a dedicated QA team to review changes, but this can often cause 132 | slowdowns in your deployment process. Automated tests, written by 133 | the developers who add the features, intend to run similar checks 134 | against a local or deployed application to verify the correctness of 135 | the website. 136 |

137 | 138 |

139 | I use cypress at work since I find it easy to use, but there is also 140 | another testing framework called playwright which I hear great 141 | things about. I'll try to experiment with playwright testing when I 142 | get some time. For my integration and unit tests, I plan to use Jest 143 | since that is what I'm used to, but I also hear vitest is a very 144 | fast testing framework that might be worth transitioning to due to 145 | performance reasons. 146 |

147 | 148 |

149 | If you are interested in learning more about testing with jest or 150 | cypress, be sure to check out my 151 | first live stream. 156 |

157 | 158 | 159 | 164 | 165 | 166 |

167 | and 168 | second live stream. 173 |

174 | 175 | 176 | 181 | 182 | 183 |

The Importance of Problem Solving

184 | 185 |

186 | When you are first learning how to code, there are many challenges 187 | you will have to overcome. For example, there are new things I 188 | encountered while trying to build out this newsletter application. 189 | Styling HTML emails is very difficult, so thankfully I stumbled upon 190 | something called MJML which helps you write email templates that 191 | convert to HTML. A lot of the problems I encountered can be solved 192 | by following a simple approach of breaking the problem down into 193 | small problems. 194 |

195 | 196 |

197 | All software involves problem-solving. Some of the problems have 198 | been solved by others, but you'll still need to search google for 199 | those solutions and piece the puzzles together. Sometimes you'll run 200 | into problems you have to solve yourself. Overall, the only way to 201 | get great at coding is to practice solving more and more problems 202 | until most problems you encounter are ones you've solved in the 203 | past. 204 |

205 | 206 |

207 | Most larger problems are made up of smaller sub-problems. Check out 208 | the following 209 | video 214 | to get a better understanding of how to break down a problem 215 |

216 | 217 | 218 | 223 | 224 | 225 |

Our community Dungeon Crawler is coming along

226 | 227 |

228 | For those who don't know, I've been trying to spend some time this 229 | week working on a dungeon crawler game with some other discord 230 | users. I've always wanted to finish one game, so I'm hoping this 231 | project can at least get to an MVP state where some subscribers can 232 | play and have some fun playing. The main focus I've been working on 233 | is the inventory system to allow users to pick up items, drop items, 234 | use items, and equip items. I plan to add in the ability for players 235 | to equip certain types of items this week, so stay tuned for an 236 | updated video related. 237 |

238 | 239 |

240 | To follow along with this project, be sure to check out my 241 | video 246 | where I start working on the inventory system 247 |

248 | 249 | 250 | 255 | 256 | 257 |

Stay tuned for updates

258 |

259 | That was a short update with the things I've been working on and 260 | just some random thoughts on the software. If you have feedback for 261 | me regarding this newsletter, feel free to join my discord and send 262 | me a message. 263 |

264 | 265 |

Have a good day and happy coding!

266 |
267 |
268 |
269 |
270 |
271 | -------------------------------------------------------------------------------- /data/emails/jan29.txt: -------------------------------------------------------------------------------- 1 | The First Email 2 | Since this is my first newsletter email, I want to start by saying thank you all so much for subscribing to my channel. I'm happy to know that my content has not only helped many of you learn to code but also some have said they have landed internships and jobs from watching my videos. 3 | 4 | So what is the purpose of this newsletter? To be honest, I don't know yet, but I plan to hopefully use it as another way to send you all updates on my channel, links to useful resources, maybe links to exclusive videos I may record, and also maybe some little tips and tricks related to coding or the tech stack I'm currently using (typescript, tailwind, next, react, etc). 5 | 6 | If you don't find this content useful, you can unsubscribe using the link at the bottom of this email, but I would ask you to give me some feedback if you think of ways I could provide more value to y'all using this newsletter. 7 | 8 | What have I been up to? 9 | This week I've been focusing on building up my brand and trying to put more effort into marketing my channel. I set up a twitch channel channel which I've been multicast streaming to when I do my normal youtube streams, so be sure to follow if you like watching streams on twitch. 10 | 11 | As you already know, I recently changed my channel brand from Web Dev Junkie to Web Dev Cody. The main reason was I wanted to get my name in my channel and decided to do it before I got to 100k subscribers. Also, the word Junkie doesn't have the best connotation and isn't that respectful for people will real drug addiction issues. 12 | 13 | Another approach I'm taking to growing my brand is working on a personal website which I may use as a portfolio and maybe adding a blog where I can also publish some of these newsletter updates if that might be interesting to read for y'all. 14 | 15 | My personal site using Astro 16 | I've been hearing some good things about Astro, so I decided to set up a personal website using Astro. I will be honest, I only created a single page with the Astro site, but overall I think this framework is easier to work with compared to Next.js. Don't get me wrong, next.js is a great framework, but it can be a little confusing to learn at first. If you're looking into a simple way to create statically generated sites, Astro might be your solution. It has the power to create minimal webpages that contain zero javascript unless you manually add in interactivity. 17 | 18 | I also decided to deploy this site using AWS Amplify. Amplify is another piece of technology I hear a lot about, and I use many AWS services at work so I figured I'd give it a try. Amplify isn't as user-friendly as something like vercel or railway, but it will build and deploy your next application to the CloudFront CDN which means your static files will load fast for your users. It's also really cheap to deploy using amplify. 19 | 20 | You can watch my video of me talking about how I hosted my astro site using AWS amplify the following video 21 | 22 | Making my newsletter production ready 23 | I published two live streams this week where I worked on adding some integration and e2e cypress tests on my newsletter application. 24 | 25 | Learning how to write tests is critical for building quality software applications. When you're on a large team of engineers who are committing code multiple times daily, it is very easy to accidentally break a feature for your users. Some projects may have a dedicated QA team to review changes, but this can often cause slowdowns in your deployment process. Automated tests, written by the developers who add the features, intend to run similar checks against a local or deployed application to verify the correctness of the website. 26 | 27 | I use cypress at work since I find it easy to use, but there is also another testing framework called playwright which I hear great things about. I'll try to experiment with playwright testing when I get some time. For my integration and unit tests, I plan to use Jest since that is what I'm used to, but I also hear vitest is a very fast testing framework that might be worth transitioning to due to performance reasons. 28 | 29 | If you are interested in learning more about testing with jest or cypress, be sure to check out my first live stream. 30 | 31 | 32 | and second live stream. 33 | 34 | 35 | The Importance of Problem Solving 36 | When you are first learning how to code, there are many challenges you will have to overcome. For example, there are new things I encountered while trying to build out this newsletter application. Styling HTML emails is very difficult, so thankfully I stumbled upon something called MJML which helps you write email templates that convert to HTML. A lot of the problems I encountered can be solved by following a simple approach of breaking the problem down into small problems. 37 | 38 | All software involves problem-solving. Some of the problems have been solved by others, but you'll still need to search google for those solutions and piece the puzzles together. Sometimes you'll run into problems you have to solve yourself. Overall, the only way to get great at coding is to practice solving more and more problems until most problems you encounter are ones you've solved in the past. 39 | 40 | Most larger problems are made up of smaller sub-problems. Check out the following video to get a better understanding of how to break down a problem 41 | 42 | 43 | Our community Dungeon Crawler is coming along 44 | For those who don't know, I've been trying to spend some time this week working on a dungeon crawler game with some other discord users. I've always wanted to finish one game, so I'm hoping this project can at least get to an MVP state where some subscribers can play and have some fun playing. The main focus I've been working on is the inventory system to allow users to pick up items, drop items, use items, and equip items. I plan to add in the ability for players to equip certain types of items this week, so stay tuned for an updated video related. 45 | 46 | To follow along with this project, be sure to check out my video where I start working on the inventory system 47 | 48 | 49 | Stay tuned for updates 50 | That was a short update with the things I've been working on and just some random thoughts on the software. If you have feedback for me regarding this newsletter, feel free to join my discord and send me a message. 51 | 52 | Have a good day and happy coding! -------------------------------------------------------------------------------- /data/emails/rating-html-tips.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Just Learn HTML and CSS!

11 | 12 |

So, you're new to web development and overwhelmed with how much there is to learn. Many people online regurgitate the same "just learn HTML and CSS." How much should you learn? When do you know you've learned enough before progressing to complex topics like vanilla javascript and front-end frameworks like React?

13 | 14 |

15 | My rule of thumb for when you know enough HTML and CSS is pretty simple: when you can look at many websites or even the widgets that make up the website and confidently state, "yeah, I can build this," then you're at the point where it's time to progress into more complex topics.

16 |

Why do I say this should be your goal? Well, when working as a professional web developer, you'll often work with graphic designers or user experience members who create design mock-ups or design proofs. Your job is to take these designs and convert them to code. The more efficient you can get at doing a task like this, the better you'll become as a front-end developer. 17 |

18 | 19 |

20 | A great way to improve with HTML and CSS is to follow this same pattern done in the industry by trying to replicate existing designs. Let's take this rating widget as an example below taken from frontendmentor.io: 21 | 22 | 23 | 24 |

25 | 26 | 27 |

This widget is pretty simple, but for a beginner, there are many new things you'll need to understand. How do you evenly space out buttons? How do I add hover effects on these buttons? How do I add a rounded gray background to images? How do I make a panel with rounded corners?

28 | 29 |

30 | Figuring out how to build these widgets usually always breaks down to knowing how to solve the smaller individual questions we talked about above. For example, the better you get at identifying you can use border-radius to round corners or make circles, creating this panel becomes less intimidating.

31 | 32 |

33 | 34 | Since this is a newsletter to help give tips related to web development, I might as well explain some of the css properties you can use to style this widget: 35 |

36 | 37 |
    38 |
  • border-radius: 50% - a great way to make elements circle
  • 39 |
  • border-radius: 20px - a way to round the edges on elements
  • 40 |
  • :hover - a pseudo-class styling a elements on hover
  • 41 |
  • :focus - a pseudo-class for changing the style of elements when focused
  • 42 |
  • padding: 20px - add some internal padding to the element to move content away from the edges
  • 43 |
  • background: blue - to make an elements background color blue
  • 44 |
  • display: flex - a way to change the layout of children inside an container
  • 45 |
  • justify-content: space-between - a way to add equal space between the children of the flex container
  • 46 |
  • flex-direction: column - change the axis of the flex container to instead be verical
  • 47 |
  • text-align: center - center all text and images in the center of the container
  • 48 |
49 | 50 |

51 | Those are just a few of the css properties needed to build out this widget, but the point I'm trying to make is this: solve more problems. It'll help you improve at identifying which properties and pseudo-classes you can use style your widgets and webpages. The more things you build, the more of these properties you will commit to memory so that the next time you need to center an element or round a corner it'll be muscle memory.

52 |

53 | If you're interested in watching me implement this widget myself, be sure to checkout my youtube video and my channel webdevcody 54 |

55 |
56 |
57 |
58 |
59 |
-------------------------------------------------------------------------------- /data/emails/rating-html-tips.txt: -------------------------------------------------------------------------------- 1 | Just Learn HTML and CSS! 2 | 3 | So, you're new to web development and overwhelmed with how much there is to learn. Many people online regurgitate the same "just learn HTML and CSS." How much should you learn? When do you know you've learned enough before progressing to complex topics like vanilla javascript and front-end frameworks like React? 4 | 5 | My rule of thumb for when you know enough HTML and CSS is pretty simple: when you can look at many websites or even the widgets that make up the website and confidently state, "yeah, I can build this," then you're at the point where it's time to progress into more complex topics. 6 | 7 | Why do I say this should be your goal? Well, when working as a professional web developer, you'll often work with graphic designers or user experience members who create design mock-ups or design proofs. Your job is to take these designs and convert them to code. The more efficient you can get at doing a task like this, the better you'll become as a front-end developer. 8 | 9 | A great way to improve with HTML and CSS is to follow this same pattern done in the industry by trying to replicate existing designs. Let's take this rating widget as an example below taken from frontendmentor.io: 10 | 11 | This widget is pretty simple, but for a beginner, there are many new things you'll need to understand. How do you evenly space out buttons? How do I add hover effects on these buttons? How do I add a rounded gray background to images? How do I make a panel with rounded corners? 12 | 13 | Figuring out how to build these widgets usually always breaks down to knowing how to solve the smaller individual questions we talked about above. For example, the better you get at identifying you can use border-radius to round corners or make circles, creating this panel becomes less intimidating. 14 | 15 | Since this is a newsletter to help give tips related to web development, I might as well explain some of the css properties you can use to style this widget: 16 | 17 | border-radius: 50% - a great way to make elements circle 18 | border-radius: 20px - a way to round the edges on elements 19 | :hover - a pseudo-class styling a elements on hover 20 | :focus - a pseudo-class for changing the style of elements when focused 21 | padding: 20px - add some internal padding to the element to move content away from the edges 22 | background: blue - to make an elements background color blue 23 | display: flex - a way to change the layout of children inside an container 24 | justify-content: space-between - a way to add equal space between the children of the flex container 25 | flex-direction: column - change the axis of the flex container to instead be verical 26 | text-align: center - center all text and images in the center of the container 27 | Those are just a few of the css properties needed to build out this widget, but the point I'm trying to make is this: solve more problems. It'll help you improve at identifying which properties and pseudo-classes you can use style your widgets and webpages. The more things you build, the more of these properties you will commit to memory so that the next time you need to center an element or round a corner it'll be muscle memory. 28 | 29 | If you're interested in watching me implement this widget myself, be sure to checkout my youtube video: https://www.youtube.com/watch?v=mp1-HUjZE0o and my channel https://youtube.com/@webdevcody -------------------------------------------------------------------------------- /data/emails/t3-course.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 |

My T3 Stack Course

20 |

Hey subscribers!

21 | 22 |

23 | Those of you who have been watching my channel will know that one of 24 | the recent projects I've worked on was a full-stack SaaS that 25 | generated icons using artificial intelligence. I really enjoyed 26 | working on this side project and it uses many useful technologies 27 | that I feel others should learn if trying to become a web developer. 28 |

29 | 30 |

31 | As I mentioned in my channel, I've been working on a short course to 32 | help teach others how to build out a similar application that uses 33 | the same technologies that I used on my production-ready 34 | application. 35 |

36 | 37 |

38 | Well, great news! I've finally managed to finish my course that 39 | you're welcome to go purchase if learning the same technologies I 40 | used sounds interesting to you. 41 |

42 | 43 |

44 | Here is the link to the course: 45 | 46 | https://1017897100294.gumroad.com/l/jipjfm. Be sure to use the following code of 9LX2QSM to get a 30% 48 | discount. 49 |

50 | 51 |

What's Will You Learn?

52 | 53 |

54 | As mentioned, the following technologies are used in this course. 55 |

56 | 57 |
    58 |
  • T3 Stack
  • 59 |
  • Typescript
  • 60 |
  • Prisma
  • 61 |
  • tRPC
  • 62 |
  • Tailwind CSS
  • 63 |
  • Next Auth
  • 64 |
  • AWS S3
  • 65 |
  • AWS Amplify
  • 66 |
  • Dall-e API
  • 67 |
  • Stripe
  • 68 |
  • and probably more...
  • 69 |
70 | 71 |

72 | The course contains 6 hours of content which is split up 73 | between 32 videos. I provide high level diagrams in the 74 | course to really help explain what we will build and how we will 75 | build it out. It has a 30 day return policy, so feel free to contact 76 | me if you are not happy with the course. 77 |

78 | 79 |

Who is this course made for?

80 | 81 |

82 | As mentioned on the course page, this course isn't for complete 83 | beginners. I expect you have some knowledge of using javascript and 84 | maybe understand how to create react components. Having some 85 | exposure to tailwind css might help as well. The way I teach this 86 | course is very similar to how I make my youtube videos, so please go 87 | watch some of my free T3 stack videos on my youtube channel to get 88 | an idea of the teaching style. 89 |

90 | 91 |

92 | Again, here is the course: 93 | 94 | https://1017897100294.gumroad.com/l/jipjfm. Be sure to use the following code of 9LX2QSM to get a 30% 96 | discount 97 |

98 | 99 |

Have a good day and happy coding!

100 |
101 |
102 |
103 |
104 |
105 | -------------------------------------------------------------------------------- /data/emails/t3-course.txt: -------------------------------------------------------------------------------- 1 | My T3 Stack Course 2 | Hey subscribers! 3 | 4 | Those of you who have been watching my channel will know that one of the recent projects I've worked on was a full-stack SaaS that generated icons using artificial intelligence. I really enjoyed working on this side project and it uses many useful technologies that I feel others should learn if trying to become a web developer. 5 | 6 | As I mentioned in my channel, I've been working on a short course to help teach others how to build out a similar application that uses the same technologies that I used on my production-ready application. 7 | 8 | Well, great news! I've finally managed to finish my course that you're welcome to go purchase if learning the same technologies I used sounds interesting to you. 9 | 10 | Here is the link to the course: https://1017897100294.gumroad.com/l/jipjfm 11 | . Be sure to use the following code of 9LX2QSM to get a 30% discount. 12 | 13 | What's Will You Learn? 14 | 15 | As mentioned, the following technologies are used in this course. 16 | 17 | T3 Stack 18 | Typescript 19 | Prisma 20 | tRPC 21 | Tailwind CSS 22 | Next Auth 23 | AWS S3 24 | AWS Amplify 25 | Dall-e API 26 | Stripe 27 | and probably more... 28 | 29 | The course contains6 hours of content which is split up between 32 videos. I provide high level diagrams in the course to really help explain what we will build and how we will build it out. It has a 30 day return policy, so feel free to contact me if you are not happy with the course. 30 | 31 | Who is this course made for? 32 | 33 | As mentioned on the course page, this course isn't for complete beginners. I expect you have some knowledge of using javascript and maybe understand how to create react components. Having some exposure to tailwind css might help as well. The way I teach this course is very similar to how I make my youtube videos, so please go watch some of my free T3 stack videos on my youtube channel to get an idea of the teaching style. 34 | 35 | Have a good day and happy coding! -------------------------------------------------------------------------------- /data/emails/welcome.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 |

20 | Welcome to the WebDevCody Newsletter! 21 |

22 | 23 |

24 | If you received this email, you're all set to get future emails when 25 | I publish weekly content updates, new courses, or useful learning 26 | materials. 27 |

28 |
29 |
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /data/emails/welcome.txt: -------------------------------------------------------------------------------- 1 | Thank you for subscribing to the WebDevCody Newsletter. Be on the lookout for updates in the future about my channel, tips and tricks on web development, links to useful learning resources, and more! -------------------------------------------------------------------------------- /data/welcome.ts: -------------------------------------------------------------------------------- 1 | export const welcome = `

Welcome to the WebDevCody Newsletter!

If you received this email, you're all set to get future emails when I publish weekly content updates, new courses, or useful learning materials.

`; 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | _build: 4 | image: newsletter-image 5 | command: ["echo", "build completed"] 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | 10 | shell: 11 | image: newsletter-image 12 | container_name: shell 13 | depends_on: 14 | - _build 15 | command: tail -f /dev/null 16 | environment: 17 | REGION: us-east-1 18 | ACCESS_KEY: local 19 | SECRET_KEY: local 20 | SES_ENDPOINT: http://ses-local:9001 21 | DYNAMO_ENDPOINT: http://dynamodb-local:8000 22 | HOST_NAME: http://localhost:3000 23 | NEXT_PUBLIC_API_URL: http://localhost:3010 24 | volumes: 25 | - .:/home/app 26 | - /home/app/node_modules 27 | 28 | ui: 29 | image: newsletter-image 30 | depends_on: 31 | - _build 32 | container_name: ui 33 | ports: 34 | - "3000:3000" 35 | entrypoint: ["yarn", "workspace", "@wdc-newsletter/ui", "dev"] 36 | environment: 37 | REGION: us-east-1 38 | ACCESS_KEY: local 39 | SECRET_KEY: local 40 | SES_ENDPOINT: http://ses-local:9001 41 | DYNAMO_ENDPOINT: http://dynamodb-local:8000 42 | HOST_NAME: http://localhost:3000 43 | NEXT_PUBLIC_RECAPTCHA_SITE_KEY: $NEXT_PUBLIC_RECAPTCHA_SITE_KEY 44 | # for when running cypress in a container, we need to overwrite 45 | NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3010} 46 | NEXT_PUBLIC_DISABLE_RECAPTCHA: $NEXT_PUBLIC_DISABLE_RECAPTCHA 47 | volumes: 48 | - .:/home/app 49 | - /home/app/node_modules 50 | 51 | api: 52 | image: newsletter-image 53 | container_name: api 54 | ports: 55 | - "3010:3010" 56 | entrypoint: ["yarn", "workspace", "@wdc-newsletter/api", "dev"] 57 | depends_on: 58 | - _build 59 | environment: 60 | REGION: us-east-1 61 | ACCESS_KEY: local 62 | SECRET_KEY: local 63 | SES_ENDPOINT: http://ses-local:9001 64 | DYNAMO_ENDPOINT: http://dynamodb-local:8000 65 | HOST_NAME: http://localhost:3000 66 | RECAPTCHA_SECRET: $RECAPTCHA_SECRET 67 | DISABLE_RECAPTCHA: $DISABLE_RECAPTCHA 68 | volumes: 69 | - .:/home/app 70 | - /home/app/node_modules/ 71 | - /home/app/api/node_modules/ 72 | - /home/app/business/node_modules/ 73 | 74 | dynamodb-local: 75 | image: amazon/dynamodb-local:latest 76 | container_name: dynamodb-local 77 | ports: 78 | - "8000:8000" 79 | 80 | dynamo-setup: 81 | image: newsletter-image 82 | container_name: dynamo-setup 83 | depends_on: 84 | - _build 85 | - dynamodb-local 86 | environment: 87 | DYNAMO_ENDPOINT: "http://dynamodb-local:8000" 88 | REGION: "us-east-1" 89 | ACCESS_KEY: local 90 | SECRET_KEY: local 91 | restart: "no" 92 | entrypoint: 93 | ["yarn", "workspace", "@wdc-newsletter/business", "dynamo:setup"] 94 | volumes: 95 | - .:/home/app 96 | - /home/app/node_modules 97 | 98 | dynamodb-admin: 99 | image: aaronshaf/dynamodb-admin 100 | ports: 101 | - "8001:8001" 102 | environment: 103 | DYNAMO_ENDPOINT: "http://dynamodb-local:8000" 104 | AWS_REGION: "us-east-1" 105 | AWS_ACCESS_KEY_ID: local 106 | AWS_SECRET_ACCESS_KEY: local 107 | depends_on: 108 | - dynamodb-local 109 | 110 | ses-local: 111 | image: jdelibas/aws-ses-local 112 | ports: 113 | - "9001:9001" 114 | volumes: 115 | - "./output:/aws-ses-local/output" 116 | -------------------------------------------------------------------------------- /e2e/.gitignore: -------------------------------------------------------------------------------- 1 | cypress/videos/* 2 | cypress/screenshots/* -------------------------------------------------------------------------------- /e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import { mkdirSync, readdirSync, readFileSync, rmSync } from "fs"; 3 | import { DynamoDB } from "aws-sdk"; 4 | import { env } from "@wdc-newsletter/business"; 5 | import fsExtra from "fs-extra"; 6 | 7 | const OUTPUT_EMAIL_FILE_PATH = "./output"; 8 | 9 | const client = new DynamoDB.DocumentClient({ 10 | region: env.REGION, 11 | credentials: { 12 | accessKeyId: env.ACCESS_KEY, 13 | secretAccessKey: env.SECRET_KEY, 14 | }, 15 | endpoint: env.DYNAMO_ENDPOINT, 16 | }); 17 | 18 | type TDynamoItem = { 19 | pk: string; 20 | sk: string; 21 | }; 22 | 23 | export default defineConfig({ 24 | e2e: { 25 | baseUrl: "http://localhost:3000", 26 | viewportWidth: 1400, 27 | setupNodeEvents(on) { 28 | on("task", { 29 | recreateOutputDirectory: () => { 30 | fsExtra.emptyDirSync(OUTPUT_EMAIL_FILE_PATH); 31 | return null; 32 | }, 33 | getSentEmails: () => { 34 | const [dir] = readdirSync(OUTPUT_EMAIL_FILE_PATH); 35 | console.log("dir", dir); 36 | if (!dir) throw new Error("no emails sent"); 37 | const emailDirectory = readdirSync( 38 | `${OUTPUT_EMAIL_FILE_PATH}/${dir}` 39 | ); 40 | 41 | const allHeadersContent = emailDirectory.map((directory) => ({ 42 | headers: readFileSync( 43 | `${OUTPUT_EMAIL_FILE_PATH}${dir}/${directory}/headers.txt`, 44 | "utf-8" 45 | ), 46 | html: readFileSync( 47 | `${OUTPUT_EMAIL_FILE_PATH}/${dir}/${directory}/body.html`, 48 | "utf-8" 49 | ), 50 | })); 51 | return allHeadersContent; 52 | }, 53 | getSubscriberByEmail: (email) => { 54 | return client 55 | .get({ 56 | TableName: env.TABLE_NAME, 57 | Key: { 58 | pk: `email|${email}`, 59 | sk: `email|${email}`, 60 | }, 61 | }) 62 | .promise() 63 | .then(({ Item }) => Item ?? null); 64 | }, 65 | clearDatabase: async () => { 66 | const allItems = await client 67 | .scan({ 68 | TableName: env.TABLE_NAME, 69 | }) 70 | .promise(); 71 | if (!allItems.Items) return null; 72 | return await Promise.all( 73 | allItems.Items.map((item: Partial) => 74 | client 75 | .delete({ 76 | TableName: env.TABLE_NAME, 77 | Key: { 78 | pk: item.pk, 79 | sk: item.sk, 80 | }, 81 | }) 82 | .promise() 83 | ) 84 | ); 85 | }, 86 | }); 87 | }, 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /e2e/cypress/e2e/a_user_subscribes.cy.ts: -------------------------------------------------------------------------------- 1 | import { TSubscription } from "@wdc-newsletter/business"; 2 | 3 | const baseUrl = Cypress.env("BASE_URL") || "http://localhost:3000"; 4 | 5 | describe("a user can subscribe to my newsletter", () => { 6 | before(() => { 7 | cy.task("recreateOutputDirectory"); 8 | cy.task("clearDatabase"); 9 | }); 10 | 11 | it("loads the ui and type into the form", () => { 12 | const expectedEmail = "webdevcody@gmail.com"; 13 | cy.visit("/"); 14 | cy.get('[data-testid="email-input"]').type(expectedEmail); 15 | cy.get('[data-testid="subscribe-button"]').click(); 16 | cy.url().should("include", "/success"); 17 | 18 | cy.task("getSubscriberByEmail", expectedEmail).then( 19 | (subscriber: TSubscription) => { 20 | expect(subscriber).to.not.be.undefined; 21 | cy.visit(`/unsubscribe/${subscriber.unsubscribeId}`); 22 | 23 | cy.get('[data-testid="unsubscribe-status"]').should( 24 | "contain", 25 | "successfully unsubscribed!" 26 | ); 27 | 28 | cy.task("getSubscriberByEmail", expectedEmail).then((subscriber) => { 29 | expect(subscriber).to.be.null; 30 | }); 31 | } 32 | ); 33 | }); 34 | }); 35 | 36 | export {}; 37 | -------------------------------------------------------------------------------- /e2e/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /e2e/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | 39 | export {}; 40 | -------------------------------------------------------------------------------- /e2e/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdc-newsletter/e2e", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "cypress": "cypress run", 8 | "cypress:open": "cypress open" 9 | }, 10 | "keywords": [], 11 | "author": "Cody Seibert", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@types/node": "^18.11.18", 15 | "cypress": "^12.5.0" 16 | }, 17 | "dependencies": { 18 | "@wdc-newsletter/business": "*", 19 | "fs-extra": "^11.1.0", 20 | "typescript": "^4.9.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "types": ["cypress", "node"], 5 | "lib": ["ES2021"], 6 | "module": "commonjs", 7 | "target": "ES2021", 8 | "rootDir": ".", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "esModuleInterop": true 12 | }, 13 | "include": ["node_modules/cypress", "./**/*.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /infra/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/infra/.DS_Store -------------------------------------------------------------------------------- /infra/.gitignore: -------------------------------------------------------------------------------- 1 | /.terraform -------------------------------------------------------------------------------- /infra/.terraform-version: -------------------------------------------------------------------------------- 1 | 1.3.7 -------------------------------------------------------------------------------- /infra/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "4.53.0" 6 | constraints = "~> 4.0, 4.53.0" 7 | hashes = [ 8 | "h1:CymaUpULY6LR/rHl+4+Vs0i2jVHXMhSZuJj8VXqGIPs=", 9 | "h1:P6ZZ716SRIimw0t/SAgYbOMZtO0HDvwVQKxyHEW6aaE=", 10 | "zh:0d44171544a916adf0fa96b7d0851a49d8dec98f71f0229dfd2d178958b3996b", 11 | "zh:16945808ce26b86af7f5a77c4ab1154da786208c793abb95b8f918b4f48daded", 12 | "zh:1a57a5a30cef9a5867579d894b74f60bb99afc7ca0d030d49a80ad776958b428", 13 | "zh:2c718734ae17430d7f598ca0b4e4f86d43d66569c72076a10f4ace3ff8dfc605", 14 | "zh:46fdf6301cb2fa0a4d122d1a8f75f047b6660c24851d6a4537ee38926a86485d", 15 | "zh:53a53920b38a9e1648e85c6ee33bccf95bfcd067bffc4934a2af55621e6a6bd9", 16 | "zh:548d927b234b1914c43169224b03f641d0961a4e312e5c6508657fce27b66db4", 17 | "zh:57c847b2a5ae41ddea20b18ef006369d36bfdc4dec7f542f60e22a47f7b6f347", 18 | "zh:79f7402b581621ba69f5a07ce70299735c678beb265d114d58955d04f0d39f87", 19 | "zh:8970109a692dc4ecbda98a0969da472da4759db90ce22f2a196356ea85bb2cf7", 20 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 21 | "zh:a500cc4ffcad854dec0cf6f97751930a53c9f278f143a4355fa8892aa77c77bf", 22 | "zh:b687c20b42a8b9e9e9f56c42e3b3c6859c043ec72b8907a6e4d4b64068e11df5", 23 | "zh:e2c592e96822b78287554be43c66398f658c74c4ae3796f6b9e6d4b0f1f7f626", 24 | "zh:ff1c4a46fdc988716c6fc28925549600093fc098828237cb1a30264e15cf730f", 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /infra/dynamo.tf: -------------------------------------------------------------------------------- 1 | resource "aws_dynamodb_table" "basic-dynamodb-table" { 2 | name = "webdevcody_newsletter" 3 | billing_mode = "PAY_PER_REQUEST" 4 | hash_key = "pk" 5 | range_key = "sk" 6 | table_class = "STANDARD" 7 | 8 | attribute { 9 | name = "pk" 10 | type = "S" 11 | } 12 | 13 | attribute { 14 | name = "sk" 15 | type = "S" 16 | } 17 | 18 | attribute { 19 | name = "unsubscribeId" 20 | type = "S" 21 | } 22 | 23 | global_secondary_index { 24 | name = "gsi1" 25 | hash_key = "unsubscribeId" 26 | range_key = "pk" 27 | projection_type = "ALL" 28 | } 29 | } -------------------------------------------------------------------------------- /infra/invalidate-cloudfront.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ID=$(aws cloudfront list-distributions --query "DistributionList.Items[?Aliases.Items[?ends_with(@,'webdevcody.com')]].Id" | jq -r '.[0]') 4 | aws cloudfront create-invalidation --distribution-id $ID --paths "/*" 5 | 6 | -------------------------------------------------------------------------------- /infra/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "4.53.0" 6 | } 7 | } 8 | } 9 | 10 | provider "aws" { 11 | region = "us-east-1" 12 | } -------------------------------------------------------------------------------- /infra/ses.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ses_domain_identity" "ses_identity" { 2 | domain = "webdevcody.com" 3 | } 4 | 5 | resource "aws_ses_email_identity" "email_identity" { 6 | email = "webdevcody@gmail.com" 7 | } -------------------------------------------------------------------------------- /infra/site/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample Application 5 | 6 | 7 |

ERROR also works !!!

8 | 9 | 10 | -------------------------------------------------------------------------------- /infra/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sample Application 5 | 6 | 7 |

It works !!!

8 | 9 | 10 | -------------------------------------------------------------------------------- /infra/site/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.2.0" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = "~> 4.0" 7 | } 8 | } 9 | } 10 | 11 | provider "aws" { 12 | region = "us-east-1" 13 | alias = "aws_cloudfront" 14 | } 15 | 16 | locals { 17 | default_certs = [] 18 | acm_certs = "acm" 19 | domain_name = var.domain_name 20 | } 21 | 22 | data "aws_iam_policy_document" "s3_bucket_policy" { 23 | statement { 24 | sid = "1" 25 | 26 | actions = [ 27 | "s3:GetObject", 28 | ] 29 | 30 | resources = [ 31 | "arn:aws:s3:::${var.domain_name}/*", 32 | ] 33 | 34 | principals { 35 | type = "AWS" 36 | 37 | identifiers = [ 38 | aws_cloudfront_origin_access_identity.origin_access_identity.iam_arn, 39 | ] 40 | } 41 | } 42 | } 43 | 44 | resource "aws_s3_bucket" "s3_bucket" { 45 | bucket = var.domain_name 46 | tags = var.tags 47 | } 48 | resource "aws_s3_bucket_policy" "s3_bucket_policy" { 49 | bucket = aws_s3_bucket.s3_bucket.id 50 | policy = data.aws_iam_policy_document.s3_bucket_policy.json 51 | 52 | } 53 | 54 | resource "aws_s3_bucket_website_configuration" "s3_bucket" { 55 | bucket = aws_s3_bucket.s3_bucket.id 56 | 57 | index_document { 58 | suffix = "index.html" 59 | } 60 | 61 | error_document { 62 | key = "error.html" 63 | } 64 | } 65 | 66 | resource "aws_s3_bucket_acl" "s3_bucket" { 67 | bucket = var.domain_name 68 | acl = "private" 69 | } 70 | 71 | resource "aws_s3_bucket_versioning" "s3_bucket" { 72 | bucket = var.domain_name 73 | versioning_configuration { 74 | status = "Enabled" 75 | } 76 | } 77 | 78 | resource "aws_s3_object" "object" { 79 | count = var.upload_sample_file ? 1 : 0 80 | bucket = aws_s3_bucket.s3_bucket.bucket 81 | key = "index.html" 82 | source = "${path.module}/index.html" 83 | content_type = "text/html" 84 | etag = filemd5("${path.module}/index.html") 85 | } 86 | resource "aws_s3_object" "errorobject" { 87 | count = var.upload_sample_file ? 1 : 0 88 | bucket = aws_s3_bucket.s3_bucket.bucket 89 | key = "error.html" 90 | source = "${path.module}/error.html" 91 | content_type = "text/html" 92 | etag = filemd5("${path.module}/error.html") 93 | } 94 | 95 | data "aws_acm_certificate" "issued" { 96 | domain = var.acm_certificate_domain 97 | statuses = ["ISSUED"] 98 | } 99 | 100 | resource "aws_cloudfront_distribution" "s3_distribution" { 101 | depends_on = [ 102 | aws_s3_bucket.s3_bucket 103 | ] 104 | 105 | origin { 106 | domain_name = aws_s3_bucket.s3_bucket.bucket_regional_domain_name 107 | origin_id = "s3-cloudfront" 108 | 109 | s3_origin_config { 110 | origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path 111 | } 112 | } 113 | 114 | enabled = true 115 | is_ipv6_enabled = true 116 | default_root_object = "index.html" 117 | 118 | aliases = [local.domain_name] 119 | 120 | price_class = "PriceClass_100" 121 | 122 | default_cache_behavior { 123 | allowed_methods = [ 124 | "GET", 125 | "HEAD", 126 | ] 127 | 128 | cached_methods = [ 129 | "GET", 130 | "HEAD", 131 | ] 132 | 133 | target_origin_id = "s3-cloudfront" 134 | 135 | forwarded_values { 136 | query_string = false 137 | 138 | cookies { 139 | forward = "none" 140 | } 141 | } 142 | 143 | viewer_protocol_policy = "redirect-to-https" 144 | 145 | min_ttl = var.cloudfront_min_ttl 146 | default_ttl = var.cloudfront_default_ttl 147 | max_ttl = var.cloudfront_max_ttl 148 | } 149 | 150 | restrictions { 151 | geo_restriction { 152 | restriction_type = "none" 153 | locations = [] 154 | } 155 | } 156 | 157 | viewer_certificate { 158 | acm_certificate_arn = data.aws_acm_certificate.issued.arn 159 | ssl_support_method = "sni-only" 160 | minimum_protocol_version = "TLSv1" 161 | } 162 | 163 | custom_error_response { 164 | error_code = 403 165 | response_code = 200 166 | error_caching_min_ttl = 0 167 | response_page_path = "/index.html" 168 | } 169 | 170 | wait_for_deployment = false 171 | tags = var.tags 172 | } 173 | 174 | resource "aws_cloudfront_origin_access_identity" "origin_access_identity" { 175 | comment = "access-identity-${var.domain_name}.s3.amazonaws.com" 176 | } 177 | -------------------------------------------------------------------------------- /infra/site/outputs.tf: -------------------------------------------------------------------------------- 1 | output "cloudfront_domain_name" { 2 | value = aws_cloudfront_distribution.s3_distribution.domain_name 3 | } 4 | 5 | output "cloudfront_dist_id" { 6 | value = aws_cloudfront_distribution.s3_distribution.id 7 | } 8 | 9 | output "s3_domain_name" { 10 | value = aws_s3_bucket_website_configuration.s3_bucket.website_domain 11 | } 12 | 13 | output "website_address" { 14 | value = var.domain_name 15 | } 16 | 17 | output "s3_bucket_arn" { 18 | value = aws_s3_bucket.s3_bucket.arn 19 | } 20 | 21 | output "s3_bucket_name" { 22 | value = aws_s3_bucket.s3_bucket.id 23 | } 24 | -------------------------------------------------------------------------------- /infra/site/variables.tf: -------------------------------------------------------------------------------- 1 | variable "domain_name" { 2 | description = "domain name (or application name if no domain name available)" 3 | } 4 | 5 | variable "tags" { 6 | type = map(string) 7 | default = {} 8 | description = "tags for all the resources, if any" 9 | } 10 | 11 | variable "hosted_zone" { 12 | default = null 13 | description = "Route53 hosted zone" 14 | } 15 | 16 | variable "acm_certificate_domain" { 17 | default = null 18 | description = "Domain of the ACM certificate" 19 | } 20 | 21 | variable "upload_sample_file" { 22 | default = false 23 | description = "Upload sample html file to s3 bucket" 24 | } 25 | 26 | variable "cloudfront_min_ttl" { 27 | default = 0 28 | description = "The minimum TTL for the cloudfront cache" 29 | } 30 | 31 | variable "cloudfront_default_ttl" { 32 | default = 86400 33 | description = "The default TTL for the cloudfront cache" 34 | } 35 | 36 | variable "cloudfront_max_ttl" { 37 | default = 31536000 38 | description = "The maximum TTL for the cloudfront cache" 39 | } 40 | -------------------------------------------------------------------------------- /infra/state.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "wdc-newsletter-manager-terraform-state" 4 | key = "state/terraform.tfstate" 5 | region = "us-east-1" 6 | dynamodb_table = "terraform-state" 7 | } 8 | } 9 | 10 | 11 | resource "aws_s3_bucket" "terraform-state" { 12 | bucket = "wdc-newsletter-manager-terraform-state" 13 | acl = "private" 14 | 15 | versioning { 16 | enabled = true 17 | } 18 | } 19 | 20 | resource "aws_s3_bucket_public_access_block" "block" { 21 | bucket = aws_s3_bucket.terraform-state.id 22 | 23 | block_public_acls = true 24 | block_public_policy = true 25 | ignore_public_acls = true 26 | restrict_public_buckets = true 27 | } 28 | 29 | resource "aws_dynamodb_table" "terraform-state" { 30 | name = "terraform-state" 31 | hash_key = "LockID" 32 | billing_mode = "PAY_PER_REQUEST" 33 | 34 | attribute { 35 | name = "LockID" 36 | type = "S" 37 | } 38 | } -------------------------------------------------------------------------------- /infra/terraform.tfstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/infra/terraform.tfstate -------------------------------------------------------------------------------- /infra/terraform.tfstate.backup: -------------------------------------------------------------------------------- 1 | { 2 | "version": 4, 3 | "terraform_version": "1.3.7", 4 | "serial": 6, 5 | "lineage": "9cf0aff1-ebf2-f055-d402-e3ca877e3c2b", 6 | "outputs": {}, 7 | "resources": [ 8 | { 9 | "mode": "managed", 10 | "type": "aws_dynamodb_table", 11 | "name": "terraform-state", 12 | "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", 13 | "instances": [ 14 | { 15 | "schema_version": 1, 16 | "attributes": { 17 | "arn": "arn:aws:dynamodb:us-east-1:493255580566:table/terraform-state", 18 | "attribute": [ 19 | { 20 | "name": "LockID", 21 | "type": "S" 22 | } 23 | ], 24 | "billing_mode": "PROVISIONED", 25 | "global_secondary_index": [], 26 | "hash_key": "LockID", 27 | "id": "terraform-state", 28 | "local_secondary_index": [], 29 | "name": "terraform-state", 30 | "point_in_time_recovery": [ 31 | { 32 | "enabled": false 33 | } 34 | ], 35 | "range_key": null, 36 | "read_capacity": 20, 37 | "replica": [], 38 | "restore_date_time": null, 39 | "restore_source_name": null, 40 | "restore_to_latest_time": null, 41 | "server_side_encryption": [], 42 | "stream_arn": "", 43 | "stream_enabled": false, 44 | "stream_label": "", 45 | "stream_view_type": "", 46 | "table_class": "", 47 | "tags": null, 48 | "tags_all": {}, 49 | "timeouts": null, 50 | "ttl": [ 51 | { 52 | "attribute_name": "", 53 | "enabled": false 54 | } 55 | ], 56 | "write_capacity": 20 57 | }, 58 | "sensitive_attributes": [], 59 | "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxODAwMDAwMDAwMDAwLCJkZWxldGUiOjYwMDAwMDAwMDAwMCwidXBkYXRlIjozNjAwMDAwMDAwMDAwfSwic2NoZW1hX3ZlcnNpb24iOiIxIn0=" 60 | } 61 | ] 62 | }, 63 | { 64 | "mode": "managed", 65 | "type": "aws_kms_alias", 66 | "name": "key-alias", 67 | "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", 68 | "instances": [ 69 | { 70 | "schema_version": 0, 71 | "attributes": { 72 | "arn": "arn:aws:kms:us-east-1:493255580566:alias/terraform-bucket-key", 73 | "id": "alias/terraform-bucket-key", 74 | "name": "alias/terraform-bucket-key", 75 | "name_prefix": "", 76 | "target_key_arn": "arn:aws:kms:us-east-1:493255580566:key/35f833f5-2f9e-464f-97ce-2277a807b6af", 77 | "target_key_id": "35f833f5-2f9e-464f-97ce-2277a807b6af" 78 | }, 79 | "sensitive_attributes": [], 80 | "private": "bnVsbA==", 81 | "dependencies": [ 82 | "aws_kms_key.terraform-bucket-key" 83 | ] 84 | } 85 | ] 86 | }, 87 | { 88 | "mode": "managed", 89 | "type": "aws_kms_key", 90 | "name": "terraform-bucket-key", 91 | "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", 92 | "instances": [ 93 | { 94 | "schema_version": 0, 95 | "attributes": { 96 | "arn": "arn:aws:kms:us-east-1:493255580566:key/35f833f5-2f9e-464f-97ce-2277a807b6af", 97 | "bypass_policy_lockout_safety_check": false, 98 | "custom_key_store_id": "", 99 | "customer_master_key_spec": "SYMMETRIC_DEFAULT", 100 | "deletion_window_in_days": 10, 101 | "description": "This key is used to encrypt bucket objects", 102 | "enable_key_rotation": true, 103 | "id": "35f833f5-2f9e-464f-97ce-2277a807b6af", 104 | "is_enabled": true, 105 | "key_id": "35f833f5-2f9e-464f-97ce-2277a807b6af", 106 | "key_usage": "ENCRYPT_DECRYPT", 107 | "multi_region": false, 108 | "policy": "{\"Id\":\"key-default-1\",\"Statement\":[{\"Action\":\"kms:*\",\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::493255580566:root\"},\"Resource\":\"*\",\"Sid\":\"Enable IAM User Permissions\"}],\"Version\":\"2012-10-17\"}", 109 | "tags": null, 110 | "tags_all": {} 111 | }, 112 | "sensitive_attributes": [], 113 | "private": "bnVsbA==" 114 | } 115 | ] 116 | }, 117 | { 118 | "mode": "managed", 119 | "type": "aws_s3_bucket", 120 | "name": "terraform-state", 121 | "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", 122 | "instances": [ 123 | { 124 | "schema_version": 0, 125 | "attributes": { 126 | "acceleration_status": "", 127 | "acl": "private", 128 | "arn": "arn:aws:s3:::wdc-newsletter-manager-terraform-state", 129 | "bucket": "wdc-newsletter-manager-terraform-state", 130 | "bucket_domain_name": "wdc-newsletter-manager-terraform-state.s3.amazonaws.com", 131 | "bucket_prefix": null, 132 | "bucket_regional_domain_name": "wdc-newsletter-manager-terraform-state.s3.amazonaws.com", 133 | "cors_rule": [], 134 | "force_destroy": false, 135 | "grant": [ 136 | { 137 | "id": "811f261a17d743956aa1d7cac49f35c78fd2f1cbece56ecb30920856a7b069dd", 138 | "permissions": [ 139 | "FULL_CONTROL" 140 | ], 141 | "type": "CanonicalUser", 142 | "uri": "" 143 | } 144 | ], 145 | "hosted_zone_id": "Z3AQBSTGFYJSTF", 146 | "id": "wdc-newsletter-manager-terraform-state", 147 | "lifecycle_rule": [], 148 | "logging": [], 149 | "object_lock_configuration": [], 150 | "object_lock_enabled": false, 151 | "policy": "", 152 | "region": "us-east-1", 153 | "replication_configuration": [], 154 | "request_payer": "BucketOwner", 155 | "server_side_encryption_configuration": [ 156 | { 157 | "rule": [ 158 | { 159 | "apply_server_side_encryption_by_default": [ 160 | { 161 | "kms_master_key_id": "arn:aws:kms:us-east-1:493255580566:key/35f833f5-2f9e-464f-97ce-2277a807b6af", 162 | "sse_algorithm": "aws:kms" 163 | } 164 | ], 165 | "bucket_key_enabled": false 166 | } 167 | ] 168 | } 169 | ], 170 | "tags": null, 171 | "tags_all": {}, 172 | "timeouts": null, 173 | "versioning": [ 174 | { 175 | "enabled": true, 176 | "mfa_delete": false 177 | } 178 | ], 179 | "website": [], 180 | "website_domain": null, 181 | "website_endpoint": null 182 | }, 183 | "sensitive_attributes": [], 184 | "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxMjAwMDAwMDAwMDAwLCJkZWxldGUiOjM2MDAwMDAwMDAwMDAsInJlYWQiOjEyMDAwMDAwMDAwMDAsInVwZGF0ZSI6MTIwMDAwMDAwMDAwMH19", 185 | "dependencies": [ 186 | "aws_kms_key.terraform-bucket-key" 187 | ] 188 | } 189 | ] 190 | }, 191 | { 192 | "mode": "managed", 193 | "type": "aws_s3_bucket_public_access_block", 194 | "name": "block", 195 | "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", 196 | "instances": [ 197 | { 198 | "schema_version": 0, 199 | "attributes": { 200 | "block_public_acls": true, 201 | "block_public_policy": true, 202 | "bucket": "wdc-newsletter-manager-terraform-state", 203 | "id": "wdc-newsletter-manager-terraform-state", 204 | "ignore_public_acls": true, 205 | "restrict_public_buckets": true 206 | }, 207 | "sensitive_attributes": [], 208 | "private": "bnVsbA==", 209 | "dependencies": [ 210 | "aws_kms_key.terraform-bucket-key", 211 | "aws_s3_bucket.terraform-state" 212 | ] 213 | } 214 | ] 215 | } 216 | ], 217 | "check_results": null 218 | } 219 | -------------------------------------------------------------------------------- /load-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export $(cat $1 | xargs) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "newsletter-manager", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "main": "index.js", 7 | "scripts": { 8 | "deploy:api": "yarn workspace @wdc-newsletter/api deploy" 9 | }, 10 | "workspaces": { 11 | "packages": [ 12 | "business", 13 | "ui", 14 | "e2e", 15 | "api", 16 | "scripts" 17 | ] 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/webdevcody/newsletter-manager.git" 22 | }, 23 | "keywords": [], 24 | "author": "Cody Seibert", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/webdevcody/newsletter-manager/issues" 28 | }, 29 | "homepage": "https://github.com/webdevcody/newsletter-manager#readme", 30 | "devDependencies": { 31 | "cypress": "^12.5.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /scripts/integration_tests/sendEmailsCli.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { mkdirSync, readdirSync, readFileSync, rmSync } from "fs"; 3 | import fsExtra from "fs-extra"; 4 | import { 5 | env, 6 | getSubscriptionByEmailFactory, 7 | saveSubscriptionFactory, 8 | sendEmailFactory, 9 | subscribeUseCase, 10 | TSesConfig, 11 | } from "@wdc-newsletter/business"; 12 | import { expect, describe, it } from "@jest/globals"; 13 | import { TDynamoConfig } from "@wdc-newsletter/business/src/persistence/dynamo"; 14 | 15 | const OUTPUT_EMAIL_FILE_PATH = "../output"; 16 | 17 | const dynamoConfig: TDynamoConfig = { 18 | region: env.REGION, 19 | accessKeyId: env.ACCESS_KEY, 20 | secretAccessKey: env.SECRET_KEY, 21 | endpoint: env.DYNAMO_ENDPOINT, 22 | }; 23 | 24 | /** 25 | * This integration test expects the docker-compose to be up. 26 | */ 27 | describe("sendEmails command line tool", () => { 28 | beforeEach(() => { 29 | jest.setTimeout(30000); 30 | }); 31 | 32 | it("should send out emails to the expected subscribed emails in our database", async () => { 33 | fsExtra.emptyDirSync(OUTPUT_EMAIL_FILE_PATH); 34 | 35 | const subscriberEmails = ["webdevcody@gmail.com", "bob@example.com"]; 36 | 37 | for (const email of subscriberEmails) { 38 | await subscribeUseCase( 39 | { 40 | getSubscriptionByEmail: getSubscriptionByEmailFactory(dynamoConfig), 41 | saveSubscription: saveSubscriptionFactory(dynamoConfig), 42 | }, 43 | email 44 | ); 45 | } 46 | 47 | await new Promise((resolve, reject) => 48 | exec( 49 | 'npx tsx ./src/sendEmailsCli.ts "welcome to the jungle" "../data/emails/welcome.mjml"', 50 | (err, stdout) => { 51 | console.log(stdout); 52 | if (err) reject(err); 53 | resolve(); 54 | } 55 | ) 56 | ); 57 | 58 | const [dir] = readdirSync(OUTPUT_EMAIL_FILE_PATH); 59 | if (!dir) throw new Error("no emails sent"); 60 | const emailDirectory = readdirSync(`${OUTPUT_EMAIL_FILE_PATH}/${dir}`); 61 | expect(emailDirectory.length).toEqual(2); 62 | 63 | const allHeadersContent = emailDirectory.map((directory) => 64 | readFileSync( 65 | `${OUTPUT_EMAIL_FILE_PATH}/${dir}/${directory}/headers.txt`, 66 | "utf-8" 67 | ) 68 | ); 69 | 70 | subscriberEmails.forEach((email) => { 71 | expect( 72 | allHeadersContent.some((content) => 73 | content.includes(`To Address: ${email}\n`) 74 | ) 75 | ).toBe(true); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /scripts/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testTimeout: 30000, 6 | }; 7 | -------------------------------------------------------------------------------- /scripts/out.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | 29 | 34 | 35 | 36 | 40 | 43 | 44 | 45 | 46 | 47 | 48 |
51 | 52 |
53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdc-newsletter/scripts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "import": "tsx src/import.ts", 8 | "test:unit": "jest src", 9 | "test:integration": "jest integration_tests", 10 | "remove-bounced": "tsx ./src/removeBouncedEmailCli.ts" 11 | }, 12 | "keywords": [], 13 | "author": "Cody Seibert", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@jest/globals": "^29.4.1", 17 | "@types/node": "^18.11.18", 18 | "tsx": "^4.7.0" 19 | }, 20 | "dependencies": { 21 | "@wdc-newsletter/business": "*", 22 | "aws-sdk": "^2.1306.0", 23 | "dotenv": "^16.0.3", 24 | "fs-extra": "^11.1.0", 25 | "mjml": "^4.13.0", 26 | "uuid": "^9.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/src/.gitignore: -------------------------------------------------------------------------------- 1 | emails.json -------------------------------------------------------------------------------- /scripts/src/expectedHtml.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 19 | 29 | 34 | 35 | 36 | 40 | 43 | 44 | 45 | 46 | 47 | 48 |
51 | 52 |
53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /scripts/src/import.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { DynamoDB } from "aws-sdk"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | import fs from "fs"; 5 | import path from "path"; 6 | 7 | const emails = JSON.parse( 8 | fs.readFileSync(path.join(__dirname, "./emails.json"), "utf-8") 9 | ) as string[]; 10 | 11 | const client = new DynamoDB.DocumentClient({ 12 | region: process.env.REGION, 13 | credentials: { 14 | accessKeyId: process.env.ACCESS_KEY!, 15 | secretAccessKey: process.env.SECRET_KEY!, 16 | }, 17 | }); 18 | 19 | async function subscribe(email: string) { 20 | const { Item: subscription } = await client 21 | .get({ 22 | TableName: process.env.TABLE_NAME, 23 | Key: { 24 | pk: `email|${email}`, 25 | sk: `email|${email}`, 26 | }, 27 | }) 28 | .promise(); 29 | 30 | if (subscription) { 31 | console.info(`skipping ${email} - already subscribed.`); 32 | return; 33 | } 34 | 35 | const unsubscribeId = uuidv4(); 36 | await client 37 | .put({ 38 | TableName: process.env.TABLE_NAME, 39 | Item: { 40 | pk: `email|${email}`, 41 | sk: `email|${email}`, 42 | email: email, 43 | unsubscribeId, 44 | }, 45 | }) 46 | .promise(); 47 | } 48 | 49 | async function main() { 50 | for (const email of emails) { 51 | console.time(`subscribing ${email}`); 52 | await subscribe(email); 53 | console.timeEnd(`subscribing ${email}`); 54 | } 55 | } 56 | 57 | main().catch(console.error); 58 | -------------------------------------------------------------------------------- /scripts/src/migration.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { remove, scan } from "@wdc-newsletter/business/src/persistence/dynamo"; 3 | import { getDynamoConfig } from "./util/getConfigs"; 4 | import { verifyEnv } from "./util/verifyEnv"; 5 | 6 | ["REGION", "ACCESS_KEY", "SECRET_KEY", "DYNAMO_ENDPOINT"].forEach(verifyEnv); 7 | 8 | const dynamoConfig = getDynamoConfig(); 9 | 10 | async function main() { 11 | const subscriptions = await scan(dynamoConfig); 12 | const toDeleteList = subscriptions.filter((item) => 13 | item.pk.startsWith("subscription|") 14 | ); 15 | 16 | for (const toDelete of toDeleteList) { 17 | console.log("deleting", toDelete); 18 | await remove(dynamoConfig, { 19 | pk: toDelete.pk, 20 | sk: toDelete.sk, 21 | }); 22 | } 23 | } 24 | 25 | main().catch(console.error); 26 | -------------------------------------------------------------------------------- /scripts/src/removeBouncedEmail.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TGetSubscriptionByEmail, 3 | TRemoveBouncedEmailUseCase, 4 | TRemoveSubscription, 5 | } from "@wdc-newsletter/business"; 6 | 7 | export async function removeBouncedEmail({ 8 | getArguments, 9 | removeBouncedEmailUseCase, 10 | getSubscriptionByEmail, 11 | removeSubscription, 12 | }: { 13 | getArguments: () => string[]; 14 | removeBouncedEmailUseCase: TRemoveBouncedEmailUseCase; 15 | getSubscriptionByEmail: TGetSubscriptionByEmail; 16 | removeSubscription: TRemoveSubscription; 17 | }) { 18 | const [email] = getArguments(); 19 | 20 | if (!email) { 21 | throw new Error("email is required as an argument but was not provided"); 22 | } 23 | 24 | await removeBouncedEmailUseCase( 25 | { 26 | getSubscriptionByEmail, 27 | removeSubscription, 28 | }, 29 | email 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /scripts/src/removeBouncedEmailCli.ts: -------------------------------------------------------------------------------- 1 | import { 2 | removeBouncedEmailUseCase, 3 | getSubscriptionByEmailFactory, 4 | removeSubscriptionFactory, 5 | } from "@wdc-newsletter/business"; 6 | import { removeBouncedEmail } from "./removeBouncedEmail"; 7 | import { getDynamoConfig } from "./util/getConfigs"; 8 | import { verifyEnv } from "./util/verifyEnv"; 9 | 10 | ["REGION", "ACCESS_KEY", "SECRET_KEY", "DYNAMO_ENDPOINT"].forEach(verifyEnv); 11 | 12 | const dynamoConfig = getDynamoConfig(); 13 | 14 | const email = process.argv?.slice(2)[0]; 15 | 16 | removeBouncedEmail({ 17 | getArguments: () => [email], 18 | removeBouncedEmailUseCase, 19 | getSubscriptionByEmail: getSubscriptionByEmailFactory(dynamoConfig), 20 | removeSubscription: removeSubscriptionFactory(dynamoConfig), 21 | }) 22 | .catch((error) => { 23 | throw error; 24 | }) 25 | .finally(() => { 26 | console.info(`${email} was removed`); 27 | }); 28 | -------------------------------------------------------------------------------- /scripts/src/sendEmails.test.ts: -------------------------------------------------------------------------------- 1 | import { sendEmails } from "./sendEmails"; 2 | import fs from "fs"; 3 | import { describe, expect, it, jest } from "@jest/globals"; 4 | 5 | const expectedHtml = fs.readFileSync("./src/expectedHtml.html", "utf-8"); 6 | 7 | describe("sendEmails", () => { 8 | it("should throw an error if subject is not provided for the first argument", async () => { 9 | await expect( 10 | sendEmails({ 11 | readFile: jest.fn(() => "contents"), 12 | getArguments: () => [], 13 | sendNewsletterUseCase: jest.fn(() => Promise.resolve()), 14 | getSubscriptions: async () => [], 15 | sendEmail: async () => undefined, 16 | }) 17 | ).rejects.toThrow( 18 | "subject is required as an argument $1 but was not provided" 19 | ); 20 | }); 21 | 22 | it("should throw an error if mjml file path is not provided for the second argument", async () => { 23 | await expect( 24 | sendEmails({ 25 | readFile: jest.fn(() => "contents"), 26 | getArguments: () => ["welcome to webdevcody newsletter"], 27 | sendNewsletterUseCase: jest.fn(() => Promise.resolve()), 28 | getSubscriptions: async () => [], 29 | sendEmail: async () => undefined, 30 | }) 31 | ).rejects.toThrow( 32 | "mjml file path is required as an argument $2 but was not provided" 33 | ); 34 | }); 35 | 36 | it("should attempt to load in the mjml file, convert it to html, and send it out using the use case", async () => { 37 | const sendNewsletterUseCaseSpy = jest.fn(() => Promise.resolve()); 38 | await sendEmails({ 39 | readFile: () => "", 40 | getArguments: () => [ 41 | "welcome to webdevcody newsletter", 42 | "/some/path/to/a/file", 43 | ], 44 | sendNewsletterUseCase: sendNewsletterUseCaseSpy, 45 | getSubscriptions: async () => [], 46 | sendEmail: async () => undefined, 47 | }); 48 | 49 | const secondArgument = (sendNewsletterUseCaseSpy.mock.calls[0] as any)[1]; 50 | expect(secondArgument).toEqual({ 51 | subject: "welcome to webdevcody newsletter", 52 | body: expectedHtml, 53 | text: "", 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /scripts/src/sendEmails.ts: -------------------------------------------------------------------------------- 1 | import mjml2html from "mjml"; 2 | import { 3 | TGetSubscriptions, 4 | TSendEmail, 5 | type TSendNewsletterUseCase, 6 | } from "@wdc-newsletter/business"; 7 | 8 | export async function sendEmails({ 9 | getArguments, 10 | readFile, 11 | sendNewsletterUseCase, 12 | getSubscriptions, 13 | sendEmail, 14 | }: { 15 | getArguments: () => string[]; 16 | readFile: (file: string) => string; 17 | sendNewsletterUseCase: TSendNewsletterUseCase; 18 | getSubscriptions: TGetSubscriptions; 19 | sendEmail: TSendEmail; 20 | }) { 21 | const [subject, mjmlFilePath] = getArguments(); 22 | 23 | if (!subject) { 24 | throw new Error( 25 | "subject is required as an argument $1 but was not provided" 26 | ); 27 | } 28 | 29 | if (!mjmlFilePath) { 30 | throw new Error( 31 | "mjml file path is required as an argument $2 but was not provided" 32 | ); 33 | } 34 | const mjmlToConvert = readFile(mjmlFilePath); 35 | const text = readFile(mjmlFilePath.replace(".mjml", ".txt")); 36 | const { html } = mjml2html(mjmlToConvert); 37 | await sendNewsletterUseCase( 38 | { 39 | getSubscriptions, 40 | sendEmail, 41 | }, 42 | { subject, body: html, text } 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /scripts/src/sendEmailsCli.ts: -------------------------------------------------------------------------------- 1 | import { sendEmails } from "./sendEmails"; 2 | import { 3 | getSubscriptionsFactory, 4 | sendEmailFactory, 5 | sendNewsletterUseCase, 6 | } from "@wdc-newsletter/business"; 7 | import fs from "fs"; 8 | import { verifyEnv } from "./util/verifyEnv"; 9 | import { getDynamoConfig, getSesConfig } from "./util/getConfigs"; 10 | 11 | [ 12 | "REGION", 13 | "ACCESS_KEY", 14 | "SECRET_KEY", 15 | "DYNAMO_ENDPOINT", 16 | "SES_ENDPOINT", 17 | ].forEach(verifyEnv); 18 | 19 | const sesConfig = getSesConfig(); 20 | const dynamoConfig = getDynamoConfig(); 21 | 22 | sendEmails({ 23 | getArguments: () => process.argv?.slice(2), 24 | readFile: (filePath: string) => { 25 | return fs.readFileSync(filePath, "utf-8"); 26 | }, 27 | sendNewsletterUseCase, 28 | getSubscriptions: getSubscriptionsFactory(dynamoConfig), 29 | sendEmail: sendEmailFactory(sesConfig), 30 | }).catch((error) => { 31 | throw error; 32 | }); 33 | -------------------------------------------------------------------------------- /scripts/src/util/getConfigs.ts: -------------------------------------------------------------------------------- 1 | import { TSesConfig } from "@wdc-newsletter/business"; 2 | import { TDynamoConfig } from "@wdc-newsletter/business/src/persistence/dynamo"; 3 | 4 | export function getSesConfig(): TSesConfig { 5 | return { 6 | region: process.env.REGION, 7 | accessKeyId: process.env.ACCESS_KEY, 8 | secretAccessKey: process.env.SECRET_KEY, 9 | endpoint: process.env.SES_ENDPOINT, 10 | }; 11 | } 12 | 13 | export function getDynamoConfig(): TDynamoConfig { 14 | return { 15 | region: process.env.REGION, 16 | accessKeyId: process.env.ACCESS_KEY, 17 | secretAccessKey: process.env.SECRET_KEY, 18 | endpoint: process.env.DYNAMO_ENDPOINT, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /scripts/src/util/verifyEnv.ts: -------------------------------------------------------------------------------- 1 | export function verifyEnv(name: string) { 2 | if (!process.env[name]) { 3 | throw new Error(`env of ${name} was not set`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2021"], 4 | "module": "commonjs", 5 | "target": "ES2021", 6 | "rootDir": ".", 7 | "sourceMap": true, 8 | "outDir": "dist", 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src/**/*.ts"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /ui/.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /ui/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "extends": [ 5 | "plugin:@typescript-eslint/recommended-requiring-type-checking" 6 | ], 7 | "files": ["*.ts", "*.tsx"], 8 | "parserOptions": { 9 | "project": "./tsconfig.json" 10 | } 11 | } 12 | ], 13 | "root": true, 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "project": "./tsconfig.json" 17 | }, 18 | "plugins": ["@typescript-eslint"], 19 | "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], 20 | "rules": { 21 | "@typescript-eslint/consistent-type-imports": "warn" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | /.open-next 9 | /.sst 10 | 11 | # testing 12 | /coverage 13 | 14 | # database 15 | /prisma/db.sqlite 16 | /prisma/db.sqlite-journal 17 | 18 | # next.js 19 | /.next/ 20 | /out/ 21 | next-env.d.ts 22 | 23 | # production 24 | /build 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | .pnpm-debug.log* 35 | 36 | # local env files 37 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 38 | .env 39 | .env*.local 40 | .env*.prod 41 | 42 | # vercel 43 | .vercel 44 | 45 | # typescript 46 | *.tsbuildinfo 47 | 48 | /output -------------------------------------------------------------------------------- /ui/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Web Dev Cody 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ui/cdk.context.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /ui/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | import { mkdirSync, readdirSync, readFileSync, rmSync } from "fs"; 3 | import { DynamoDB } from "aws-sdk"; 4 | import { TABLE_NAME } from "../src/config/constants"; 5 | 6 | const client = new DynamoDB.DocumentClient({ 7 | region: "us-east-1", 8 | credentials: { 9 | accessKeyId: "local", 10 | secretAccessKey: "local", 11 | }, 12 | endpoint: "http://localhost:8000", 13 | }); 14 | 15 | type TDynamoItem = { 16 | pk: string; 17 | sk: string; 18 | }; 19 | 20 | export default defineConfig({ 21 | e2e: { 22 | setupNodeEvents(on) { 23 | on("task", { 24 | recreateOutputDirectory: () => { 25 | rmSync("./output", { recursive: true, force: true }); 26 | mkdirSync("./output"); 27 | return null; 28 | }, 29 | getSentEmails: () => { 30 | const [dir] = readdirSync("./output"); 31 | if (!dir) throw new Error("no emails sent"); 32 | const emailDirectory = readdirSync(`./output/${dir}`); 33 | 34 | const allHeadersContent = emailDirectory.map((directory) => ({ 35 | headers: readFileSync( 36 | `./output/${dir}/${directory}/headers.txt`, 37 | "utf-8" 38 | ), 39 | html: readFileSync( 40 | `./output/${dir}/${directory}/body.html`, 41 | "utf-8" 42 | ), 43 | })); 44 | return allHeadersContent; 45 | }, 46 | getSubscriberByEmail: (email) => { 47 | return client 48 | .get({ 49 | TableName: TABLE_NAME, 50 | Key: { 51 | pk: `email|${email}`, 52 | sk: `email|${email}`, 53 | }, 54 | }) 55 | .promise() 56 | .then(({ Item }) => Item ?? null); 57 | }, 58 | clearDatabase: async () => { 59 | const allItems = await client 60 | .scan({ 61 | TableName: TABLE_NAME, 62 | }) 63 | .promise(); 64 | if (!allItems.Items) return null; 65 | return await Promise.all( 66 | allItems.Items.map((item: Partial) => 67 | client 68 | .delete({ 69 | TableName: TABLE_NAME, 70 | Key: { 71 | pk: item.pk, 72 | sk: item.sk, 73 | }, 74 | }) 75 | .promise() 76 | ) 77 | ); 78 | }, 79 | }); 80 | }, 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /ui/design/design.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/design/design.webp -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | }; 6 | -------------------------------------------------------------------------------- /ui/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {import("next").NextConfig} */ 4 | const config = { 5 | reactStrictMode: true, 6 | images: { 7 | unoptimized: true, 8 | }, 9 | }; 10 | export default config; 11 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wdc-newsletter/ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start", 10 | "deploy": "sst deploy --stage prod" 11 | }, 12 | "dependencies": { 13 | "@aws-cdk/aws-certificatemanager": "^1.200.0", 14 | "@aws-cdk/core": "^1.200.0", 15 | "@next/font": "13.1.5", 16 | "@tanstack/react-query": "^4.20.0", 17 | "@trpc/client": "^10.8.1", 18 | "@trpc/next": "^10.8.1", 19 | "@trpc/react-query": "^10.8.1", 20 | "@trpc/server": "^10.8.1", 21 | "@types/react-google-recaptcha": "^2.1.5", 22 | "aws-sdk": "^2.1299.0", 23 | "classnames": "^2.3.2", 24 | "daisyui": "^2.47.0", 25 | "dotenv": "^16.0.3", 26 | "mjml": "^4.13.0", 27 | "next": "13.1.2", 28 | "next-recaptcha-v3": "^1.1.1", 29 | "react": "18.2.0", 30 | "react-dom": "18.2.0", 31 | "react-google-recaptcha": "^2.1.0", 32 | "react-query": "^3.39.3", 33 | "superjson": "1.9.1", 34 | "throttled-queue": "^2.1.4", 35 | "uuid": "^9.0.0", 36 | "zod": "^3.20.2" 37 | }, 38 | "devDependencies": { 39 | "@jest/globals": "^29.4.1", 40 | "@types/aws-sdk": "^2.7.0", 41 | "@types/jest": "^29.4.0", 42 | "@types/mjml": "^4.7.0", 43 | "@types/node": "^18.11.18", 44 | "@types/prettier": "^2.7.2", 45 | "@types/react": "^18.0.26", 46 | "@types/react-dom": "^18.0.10", 47 | "@types/uuid": "^9.0.0", 48 | "@typescript-eslint/eslint-plugin": "^5.47.1", 49 | "@typescript-eslint/parser": "^5.47.1", 50 | "autoprefixer": "^10.4.7", 51 | "aws-cdk-lib": "2.72.1", 52 | "constructs": "10.1.156", 53 | "cypress": "^12.4.1", 54 | "dynamodb-admin": "^4.5.0", 55 | "eslint": "^8.33.0", 56 | "eslint-config-next": "13.1.2", 57 | "eslint-plugin-jest": "^27.2.1", 58 | "jest": "^29.4.1", 59 | "postcss": "^8.4.14", 60 | "prettier": "^2.8.1", 61 | "prettier-plugin-tailwindcss": "^0.2.1", 62 | "sst": "^2.8.4", 63 | "tailwindcss": "^3.2.0", 64 | "ts-jest": "^29.0.5", 65 | "ts-node": "^10.9.1", 66 | "typescript": "^4.9.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ui/policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "VisualEditor0", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "ses:SendEmail", 9 | "ses:SendRawEmail", 10 | "dynamodb:PutItem", 11 | "dynamodb:GetItem", 12 | "dynamodb:DeleteItem", 13 | "dynamodb:Scan", 14 | "dynamodb:Query" 15 | ], 16 | "Resource": [ 17 | "arn:aws:ses:us-east-1:493255580566:identity/webdevcody.com", 18 | "arn:aws:dynamodb:us-east-1:493255580566:table/webdevcody_newsletter", 19 | "arn:aws:dynamodb:us-east-1:493255580566:table/webdevcody_newsletter/index/gsi1" 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /ui/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /ui/prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | -------------------------------------------------------------------------------- /ui/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /ui/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /ui/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/apple-touch-icon.png -------------------------------------------------------------------------------- /ui/public/celebrating.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/favicon.ico -------------------------------------------------------------------------------- /ui/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /ui/public/success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/success.png -------------------------------------------------------------------------------- /ui/public/wdc.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webdevcody/newsletter-manager/a5c9adde55e286de00c9d34ce2d48c882a2f4b9e/ui/public/wdc.jpeg -------------------------------------------------------------------------------- /ui/src/api/useSubscribe.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "react-query"; 2 | import { env } from "../config/constants"; 3 | 4 | export function useSubscribe() { 5 | const { mutateAsync, isLoading } = useMutation( 6 | ({ email, token }: { email: string; token: string }) => { 7 | return fetch(`${env.API_URL}/subscriptions`, { 8 | method: "POST", 9 | body: JSON.stringify({ 10 | email, 11 | token, 12 | }), 13 | headers: { 14 | "Content-Type": "application/json", 15 | }, 16 | }).then(async (response) => { 17 | if (!response.ok) { 18 | throw new Error(await response.text()); 19 | } 20 | }); 21 | } 22 | ); 23 | 24 | return { subscribe: mutateAsync, isLoading }; 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/api/useUnsubscribe.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "react-query"; 2 | import { env } from "../config/constants"; 3 | 4 | export function useUnsubscribe() { 5 | const { mutateAsync } = useMutation((unsubscribeId: string) => 6 | fetch(`${env.API_URL}/subscriptions/${unsubscribeId}`, { 7 | method: "DELETE", 8 | }) 9 | ); 10 | 11 | return { unsubscribe: mutateAsync }; 12 | } 13 | -------------------------------------------------------------------------------- /ui/src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | export function Alert(props: React.HTMLProps) { 2 | return ( 3 |
7 | 15 | 20 | 21 | 22 | {props.children} 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | interface Props extends React.ComponentPropsWithoutRef<"button"> { 4 | isLoading?: boolean; 5 | } 6 | 7 | export function Button(props: Props) { 8 | const { isLoading, ...rest } = props; 9 | 10 | return ( 11 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /ui/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { GlobeIcon } from "./icons/GlobeIcon"; 2 | import { TwitchIcon } from "./icons/TwitchIcon"; 3 | import { TwitterIcon } from "./icons/TwitterIcon"; 4 | import { YouTubeIcon } from "./icons/YouTubeIcon"; 5 | 6 | export function Footer() { 7 | return ( 8 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /ui/src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Input(props: React.HTMLProps) { 4 | return ( 5 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | export function Label(props: React.HTMLProps) { 2 | return ; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import { GlobeIcon } from "./icons/GlobeIcon"; 4 | import { TwitchIcon } from "./icons/TwitchIcon"; 5 | import { TwitterIcon } from "./icons/TwitterIcon"; 6 | import { YouTubeIcon } from "./icons/YouTubeIcon"; 7 | 8 | const links = [ 9 | { 10 | href: "https://webdevcody.com", 11 | content: ( 12 | <> 13 | 14 | Website 15 | 16 | ), 17 | }, 18 | { 19 | href: "https://youtube.com/@webdevcody", 20 | content: ( 21 | <> 22 | 23 | YouTube 24 | 25 | ), 26 | }, 27 | { 28 | href: "https://twitch.com/webdevcody", 29 | content: ( 30 | <> 31 | 32 | Twitch 33 | 34 | ), 35 | }, 36 | { 37 | href: "https://twitter.com/webdevcody", 38 | content: ( 39 | <> 40 | 41 | Twitter 42 | 43 | ), 44 | }, 45 | ]; 46 | 47 | export function NavBar() { 48 | return ( 49 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/components/icons/GlobeIcon.tsx: -------------------------------------------------------------------------------- 1 | export function GlobeIcon() { 2 | return ( 3 | 11 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/icons/TwitchIcon.tsx: -------------------------------------------------------------------------------- 1 | export function TwitchIcon() { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/components/icons/TwitterIcon.tsx: -------------------------------------------------------------------------------- 1 | export function TwitterIcon() { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/components/icons/YouTubeIcon.tsx: -------------------------------------------------------------------------------- 1 | export function YouTubeIcon() { 2 | return ( 3 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | NODE_ENV: process.env.NODE_ENV, 3 | API_URL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080", 4 | }; 5 | -------------------------------------------------------------------------------- /ui/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | /* eslint-disable @typescript-eslint/restrict-template-expressions */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 5 | import { type AppType } from "next/app"; 6 | 7 | import { QueryClient, QueryClientProvider } from "react-query"; 8 | 9 | import "../styles/globals.css"; 10 | 11 | import { Sofia_Sans } from "@next/font/google"; 12 | import { NavBar } from "../components/NavBar"; 13 | import { Footer } from "../components/Footer"; 14 | 15 | const mainFont = Sofia_Sans({ 16 | subsets: ["latin"], 17 | variable: "--font-main-font", 18 | }); 19 | const queryClient = new QueryClient(); 20 | 21 | const MyApp: AppType = ({ Component, pageProps }) => { 22 | return ( 23 | 24 |
25 | 26 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default MyApp; 34 | -------------------------------------------------------------------------------- /ui/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | const googleAnalyticsId = "G-VDVQMRCEYT"; 4 | 5 | export default class MyDocument extends Document { 6 | render() { 7 | return ( 8 | 9 | 10 |