├── .github └── workflows │ ├── analyze.yaml │ ├── deploy.yaml │ ├── destroy.yaml │ ├── health.yaml │ ├── operations.yaml │ ├── release.yaml │ └── validate.yaml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── .prettierrc.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── maildog.ts ├── cdk.json ├── commitlint.config.js ├── docs ├── architecture.png ├── enable-github-actions.png ├── installation.md └── maildog-policy.json ├── examples ├── maildog.config.json └── maildog.zonefile.txt ├── jest.config.js ├── lib ├── maildog-stack.dispatcher.ts ├── maildog-stack.scheduler.ts ├── maildog-stack.spam-filter.ts └── maildog-stack.ts ├── package-lock.json ├── package.json ├── packages ├── extensions │ ├── .babelrc │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── public │ │ ├── assets │ │ │ ├── icons │ │ │ │ ├── favicon-128.png │ │ │ │ ├── favicon-16.png │ │ │ │ ├── favicon-32.png │ │ │ │ └── favicon-48.png │ │ │ └── logo.svg │ │ └── popup.html │ ├── src │ │ ├── background │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ └── machine.ts │ │ ├── components │ │ │ └── Options.tsx │ │ ├── manifest.json │ │ ├── options.tsx │ │ ├── popup │ │ │ ├── LoginScreen.tsx │ │ │ ├── MainScreen.tsx │ │ │ ├── NavigationScreen.tsx │ │ │ ├── Popup.tsx │ │ │ ├── api.ts │ │ │ └── index.tsx │ │ ├── style.css │ │ ├── types.ts │ │ └── utils.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── webpack.config.js └── web │ ├── .gitignore │ ├── README.md │ ├── app │ ├── api.ts │ ├── auth.ts │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── github.tsx │ ├── root.tsx │ └── routes │ │ ├── 404.tsx │ │ ├── api.tsx │ │ ├── api │ │ ├── $owner.$repo.config.tsx │ │ └── session.tsx │ │ ├── callback.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ └── logout.tsx │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── remix.config.js │ ├── styles │ └── global.css │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── worker │ ├── build.js │ ├── global-fetch.js │ ├── index.ts │ ├── null.js │ └── worker.ts │ └── wrangler.toml ├── release.config.js ├── scripts └── logCdkDiffResult.ts ├── test ├── __snapshots__ │ └── maildog-stack.test.ts.snap └── maildog-stack.test.ts ├── tsconfig.json └── types └── aws-lambda-ses-forwarder.d.ts /.github/workflows/analyze.yaml: -------------------------------------------------------------------------------- 1 | name: Analyze 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | analyze: 7 | name: Code Quality 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Set up Node 11 | uses: actions/setup-node@v2 12 | with: 13 | node-version: 14.x 14 | 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Cache dependencies 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-npm- 25 | ${{ runner.OS }}- 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Copy example config 31 | run: cp ./examples/maildog.config.json maildog.config.json 32 | 33 | - name: Check types 34 | run: npm run type-checking 35 | 36 | - name: Check formatting 37 | run: npm run format-checking 38 | 39 | - name: Run test 40 | run: npm test 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy Production 2 | on: 3 | push: 4 | branches: production 5 | 6 | jobs: 7 | deploy: 8 | name: Deploy 9 | runs-on: ubuntu-latest 10 | environment: Production 11 | steps: 12 | - name: Set up Node 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 14.x 16 | 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | with: 20 | ref: production 21 | 22 | - name: Cache dependencies 23 | uses: actions/cache@v2 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: | 28 | ${{ runner.OS }}-npm- 29 | ${{ runner.OS }}- 30 | 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Configure AWS Credentials 35 | uses: aws-actions/configure-aws-credentials@v1 36 | with: 37 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 38 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 39 | aws-region: ${{ secrets.AWS_REGION }} 40 | 41 | - name: Decrypt config 42 | run: npm run decrypt-config -- --passphrase="$ENCRYPT_PASSPHRASE" maildog.config.json.gpg 43 | env: 44 | ENCRYPT_PASSPHRASE: ${{ secrets.ENCRYPT_PASSPHRASE }} 45 | 46 | - name: Deploy the CDK toolkit stack 47 | run: npx cdk bootstrap 48 | 49 | - name: Deploy maildog 50 | run: npx cdk deploy --require-approval never 51 | env: 52 | ENVIRONMENT_NAME: production 53 | 54 | - name: Activate receipt rule set 55 | run: aws ses set-active-receipt-rule-set --rule-set-name MailDog-production-ReceiptRuleSet 56 | -------------------------------------------------------------------------------- /.github/workflows/destroy.yaml: -------------------------------------------------------------------------------- 1 | name: Destroy Production 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | cleanup: 6 | description: 'Cleanup bucket & logs (yes/no)' 7 | required: true 8 | default: 'no' 9 | 10 | jobs: 11 | destroy: 12 | name: Destroy 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Set up Node 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 14.x 19 | 20 | - name: Checkout code 21 | uses: actions/checkout@v2 22 | with: 23 | ref: production 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.npm 29 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.OS }}-npm- 32 | ${{ runner.OS }}- 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Configure AWS Credentials 38 | uses: aws-actions/configure-aws-credentials@v1 39 | with: 40 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 41 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 42 | aws-region: ${{ secrets.AWS_REGION }} 43 | 44 | - name: Decrypt config 45 | run: npm run decrypt-config -- --passphrase="$ENCRYPT_PASSPHRASE" maildog.config.json.gpg 46 | env: 47 | ENCRYPT_PASSPHRASE: ${{ secrets.ENCRYPT_PASSPHRASE }} 48 | 49 | - name: Deactivate receipt rule set 50 | run: aws ses set-active-receipt-rule-set 51 | 52 | - name: Destroy stack 53 | run: npx cdk destroy --force 54 | env: 55 | ENVIRONMENT_NAME: production 56 | 57 | cleanup: 58 | name: Cleanup 59 | if: ${{ github.event.inputs.cleanup == 'yes' }} 60 | needs: destroy 61 | runs-on: ubuntu-latest 62 | steps: 63 | - name: Configure AWS Credentials 64 | uses: aws-actions/configure-aws-credentials@v1 65 | with: 66 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 67 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 68 | aws-region: ${{ secrets.AWS_REGION }} 69 | 70 | - name: Remove bucket and its content 71 | run: aws s3 rb s3://$(aws s3api list-buckets --query "Buckets[?starts_with(Name, 'maildog-production-')].Name" --output text) --force 72 | 73 | - name: Clear log groups from Lambda functions 74 | run: aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/MailDog-production-" --query "logGroups[].logGroupName" --output text | xargs -I {} -n 1 aws logs delete-log-group --log-group-name {} 75 | -------------------------------------------------------------------------------- /.github/workflows/health.yaml: -------------------------------------------------------------------------------- 1 | name: Health Check 2 | on: 3 | schedule: 4 | # Every hour 5 | - cron: '0 * * * *' 6 | 7 | jobs: 8 | alarm: 9 | name: Check Alarm 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Configure AWS Credentials 13 | uses: aws-actions/configure-aws-credentials@v1 14 | with: 15 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 16 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 17 | aws-region: ${{ secrets.AWS_REGION }} 18 | 19 | - name: Check if alarm state is OK 20 | run: aws cloudwatch describe-alarms --alarm-name-prefix MailDog-production-MailAlarm --state-value OK --output yaml | grep OK 21 | -------------------------------------------------------------------------------- /.github/workflows/operations.yaml: -------------------------------------------------------------------------------- 1 | name: Operations 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | job: 6 | description: 'Job Name (activate/redrive)' 7 | required: true 8 | 9 | jobs: 10 | activate: 11 | name: Activate receipt rule set 12 | if: ${{ github.event.inputs.job == 'activate' }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Inject slug/short variables 16 | uses: rlespinasse/github-slug-action@v3.x 17 | 18 | - name: Configure AWS Credentials 19 | uses: aws-actions/configure-aws-credentials@v1 20 | with: 21 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 22 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 23 | aws-region: ${{ secrets.AWS_REGION }} 24 | 25 | - name: Activate receipt rule set based on branch name 26 | run: aws ses set-active-receipt-rule-set --rule-set-name MailDog-${{ env.GITHUB_REF_SLUG_URL }}-ReceiptRuleSet 27 | 28 | redrive: 29 | name: Redrive DLQ Messages 30 | if: ${{ github.event.inputs.job == 'redrive' }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Inject slug/short variables 34 | uses: rlespinasse/github-slug-action@v3.x 35 | 36 | - name: Configure AWS Credentials 37 | uses: aws-actions/configure-aws-credentials@v1 38 | with: 39 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 40 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 41 | aws-region: ${{ secrets.AWS_REGION }} 42 | 43 | - name: Invoke Scheduler 44 | run: aws lambda invoke --function-name $(aws lambda list-functions --query 'Functions[?starts_with(FunctionName, `MailDog-${{ env.GITHUB_REF_SLUG_URL }}-Scheduler`) == `true`].FunctionName' --output text) --output yaml response.yaml 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | release: 9 | name: Release 10 | if: ${{ github.repository == 'edmundhung/maildog' }} 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Set up Node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 14.x 17 | 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Cache dependencies 24 | uses: actions/cache@v2 25 | with: 26 | path: ~/.npm 27 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.OS }}-npm- 30 | ${{ runner.OS }}- 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Import GPG key 36 | uses: crazy-max/ghaction-import-gpg@v3 37 | with: 38 | gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} 39 | passphrase: ${{ secrets.GPG_PASSPHRASE }} 40 | git-user-signingkey: true 41 | git-commit-gpgsign: true 42 | git-tag-gpgsign: false 43 | git-push-gpgsign: false 44 | 45 | - name: Release 46 | env: 47 | GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | GIT_AUTHOR_NAME: Maildog 50 | GIT_AUTHOR_EMAIL: maildog@edmund.dev 51 | GIT_COMMITTER_NAME: Maildog 52 | GIT_COMMITTER_EMAIL: maildog@edmund.dev 53 | run: npx semantic-release 54 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate Changes 2 | on: 3 | pull_request: 4 | types: [opened, synchronize, closed, reopened] 5 | paths: 6 | - '.github/workflows/validate.yaml' 7 | - 'lib/**' 8 | - 'bin/**' 9 | - 'examples/maildog.config.json' 10 | - 'pacakge.json' 11 | - 'package-lock.json' 12 | - 'tsconfig.json' 13 | - 'cdk.json' 14 | branches: 15 | - main 16 | 17 | jobs: 18 | validate: 19 | name: Deploy changes 20 | runs-on: ubuntu-latest 21 | if: ${{ github.actor == github.repository_owner && github.event.action != 'closed' }} 22 | environment: Test 23 | steps: 24 | - name: Set up Node 25 | uses: actions/setup-node@v1 26 | with: 27 | node-version: 14.x 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@v2 31 | 32 | - name: Cache dependencies 33 | uses: actions/cache@v2 34 | with: 35 | path: ~/.npm 36 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 37 | restore-keys: | 38 | ${{ runner.OS }}-npm- 39 | ${{ runner.OS }}- 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: Inject slug/short variables 45 | uses: rlespinasse/github-slug-action@v3.x 46 | 47 | - name: Configure AWS Credentials 48 | uses: aws-actions/configure-aws-credentials@v1 49 | with: 50 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 51 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 52 | aws-region: ${{ secrets.AWS_REGION }} 53 | 54 | - name: Copy example config 55 | run: cp ./examples/maildog.config.json maildog.config.json 56 | 57 | - name: Deploy the CDK toolkit stack 58 | run: npx cdk bootstrap 59 | 60 | - name: Deploy maildog 61 | run: npx cdk deploy --require-approval never 62 | env: 63 | ENVIRONMENT_NAME: ${{ env.GITHUB_HEAD_REF_SLUG_URL }} 64 | 65 | teardown: 66 | name: Destroy changes 67 | runs-on: ubuntu-latest 68 | if: ${{ github.actor == github.repository_owner && github.event.action == 'closed' }} 69 | environment: Test 70 | steps: 71 | - name: Set up Node 72 | uses: actions/setup-node@v1 73 | with: 74 | node-version: 14.x 75 | 76 | - name: Checkout code 77 | uses: actions/checkout@v2 78 | 79 | - name: Cache dependencies 80 | uses: actions/cache@v2 81 | with: 82 | path: ~/.npm 83 | key: ${{ runner.OS }}-npm-${{ hashFiles('**/package-lock.json') }} 84 | restore-keys: | 85 | ${{ runner.OS }}-npm- 86 | ${{ runner.OS }}- 87 | 88 | - name: Install dependencies 89 | run: npm ci 90 | 91 | - name: Inject slug/short variables 92 | uses: rlespinasse/github-slug-action@v3.x 93 | 94 | - name: Configure AWS Credentials 95 | uses: aws-actions/configure-aws-credentials@v1 96 | with: 97 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 98 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 99 | aws-region: ${{ secrets.AWS_REGION }} 100 | 101 | - name: Copy example config 102 | run: cp ./examples/maildog.config.json maildog.config.json 103 | 104 | - name: Deactivate receipt rule set 105 | run: aws ses set-active-receipt-rule-set 106 | 107 | - name: Destroy stack 108 | run: npx cdk destroy --force 109 | env: 110 | ENVIRONMENT_NAME: ${{ env.GITHUB_HEAD_REF_SLUG_URL }} 111 | 112 | - name: Remove bucket and its content 113 | run: aws s3 rb s3://$(aws s3api list-buckets --query "Buckets[?starts_with(Name, 'maildog-${{ env.GITHUB_HEAD_REF_SLUG_URL }}-')].Name" --output text) --force 114 | 115 | - name: Clear log groups from Lambda functions 116 | run: aws logs describe-log-groups --log-group-name-prefix "/aws/lambda/MailDog-${{ env.GITHUB_HEAD_REF_SLUG_URL }}-" --query "logGroups[].logGroupName" --output text | xargs -I {} -n 1 aws logs delete-log-group --log-group-name {} 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # CDK asset staging directory 108 | .cdk.staging 109 | 110 | # CDK synth output 111 | cdk.out 112 | 113 | # misc 114 | .DS_Store 115 | 116 | #maildog 117 | /maildog.config.json 118 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Diagnostic reports (https://nodejs.org/api/report.html) 2 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 3 | 4 | # Dependency directories 5 | node_modules/ 6 | jspm_packages/ 7 | 8 | # TypeScript v1 declaration files 9 | typings/ 10 | 11 | # Optional npm cache directory 12 | .npm 13 | 14 | # Optional eslint cache 15 | .eslintcache 16 | 17 | # CDK asset staging directory 18 | .cdk.staging 19 | cdk.out 20 | 21 | # Config files 22 | .babelrc 23 | maildog.config.json 24 | 25 | # Generated files 26 | CHANGELOG.md 27 | build 28 | dist 29 | packages/web/worker.js 30 | packages/web/app/styles 31 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | # .prettierrc or .prettierrc.yaml 2 | trailingComma: 'all' 3 | tabWidth: 2 4 | semi: true 5 | singleQuote: true 6 | quoteProps: 'consistent' 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.2](https://github.com/edmundhung/maildog/compare/v1.1.1...v1.1.2) (2021-08-13) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **extensions:** ensure the extensions refresh badge and context menu after decrypting the config ([39a8f56](https://github.com/edmundhung/maildog/commit/39a8f5636710a0d70bbca5f34d317e200e2cec30)) 7 | * **extensions:** incognito should not share session just like cookie ([881c8e7](https://github.com/edmundhung/maildog/commit/881c8e7d7ade3a02aec7adbf7e01569390242dc4)) 8 | 9 | 10 | ### Features 11 | 12 | * **extensions:** bootstrap extensions setup based on the web-extension-starter ([40c084b](https://github.com/edmundhung/maildog/commit/40c084b755eb64741855ccae949e8f32745bb02d)) 13 | * **extensions:** ensure emails filtering logic compares the host only ([966484d](https://github.com/edmundhung/maildog/commit/966484d648bbbff5bf47c1fc010806071f79adbe)) 14 | * **extensions:** implement decrypt config flow ([c5c73f7](https://github.com/edmundhung/maildog/commit/c5c73f7006a70c2a15ef4c8924bb0a393c33e040)) 15 | * **extensions:** implement generate email functionality ([a03be6d](https://github.com/edmundhung/maildog/commit/a03be6d386a8c497c2bd51222a5f339fa730f637)) 16 | * **extensions:** implement login / logout flow ([de9502e](https://github.com/edmundhung/maildog/commit/de9502e04ef998d3d94f607c122ecaf873ed4a77)) 17 | * **extensions:** keep track of active tab host for looking up emails ([389f560](https://github.com/edmundhung/maildog/commit/389f56069a0a74d6df0310e4a4f662a1401d3432)) 18 | * **extensions:** setup tailwindcss ([91906b4](https://github.com/edmundhung/maildog/commit/91906b4fcd954403a516a5c89ff1cf77fe9f08f8)) 19 | * **extensions:** simplify context menu setup if only 1 domain is configured ([b6308be](https://github.com/edmundhung/maildog/commit/b6308be57617d7361fff30b258a12b78190e35b4)) 20 | * **extensions:** update context menu on tab update ([7502c1d](https://github.com/edmundhung/maildog/commit/7502c1dbd4d5fb97d8905a7970f06ec0fcad7ae7)) 21 | * **web:** add get /config endpoint ([e6f14c7](https://github.com/edmundhung/maildog/commit/e6f14c73b36155ae496d6be8134cd1b058c8f49a)) 22 | * **web:** implement put /api/{owner}/{repo}/config for saving updated config file ([e6f8f3c](https://github.com/edmundhung/maildog/commit/e6f8f3cd968c1201582893c0581bfeb909fdb2d9)) 23 | 24 | ## [1.1.1](https://github.com/edmundhung/maildog/compare/v1.1.0...v1.1.1) (2021-07-24) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **web:** type-checking for web app ([c30dfab](https://github.com/edmundhung/maildog/commit/c30dfab783045252d39b4aff450b8a9ae530893f)) 30 | 31 | 32 | ### Features 33 | 34 | * **web:** implement api route with session api as example ([86814dd](https://github.com/edmundhung/maildog/commit/86814dd8e6005cbec57076e50cff01216dcbbecd)) 35 | * **web:** implement github oauth web application flow with installation support ([9688226](https://github.com/edmundhung/maildog/commit/9688226db6b9530aff84671a8723a6096e3ae01f)) 36 | * **web:** prepare a default homepage for web authentication flow ([cbe752d](https://github.com/edmundhung/maildog/commit/cbe752d6304d9af2ae77b28d81e897139d8de689)) 37 | * **web:** the website should be mobile friendly ([e8c3eed](https://github.com/edmundhung/maildog/commit/e8c3eedf397e431cd75756cbb398ec499d0fb18c)) 38 | 39 | # [1.1.0](https://github.com/edmundhung/maildog/compare/v1.0.1...v1.1.0) (2021-07-15) 40 | 41 | 42 | ### Features 43 | 44 | * add support of comment on the JSON config file with `jsonc` ([5bc1ebb](https://github.com/edmundhung/maildog/commit/5bc1ebbcf400ffe827cb4184181e7e7d77ac9b80)), closes [#7](https://github.com/edmundhung/maildog/issues/7) 45 | 46 | ## [1.0.1](https://github.com/edmundhung/maildog/compare/v1.0.0...v1.0.1) (2021-07-15) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * ensure config work as stated on documentation ([7247342](https://github.com/edmundhung/maildog/commit/7247342778deb8b1f09e1415cd05f53bad7082d9)), closes [#12](https://github.com/edmundhung/maildog/issues/12) 52 | 53 | # 1.0.0 (2021-07-11) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * activate receipt rule set after deploy ([eeb2338](https://github.com/edmundhung/maildog/commit/eeb2338039303444b3e93368898f03a06790ba83)) 59 | * deleteMessageBatch fails due to access denied ([630e74b](https://github.com/edmundhung/maildog/commit/630e74b8d0d9fbaa5c056bd6763af62d71aedb6e)) 60 | * email could be silently dropped if allowPlusSign is set to false with no fallback email ([559b1b1](https://github.com/edmundhung/maildog/commit/559b1b16cfe728d17528e4f4eed14cb93948d076)) 61 | * ensure all aws usages are covered by the example-policy ([0fc9d52](https://github.com/edmundhung/maildog/commit/0fc9d5259daf60d58c525f58dc29a238c8f964d3)) 62 | * example config should be aligned with doc ([02bb46e](https://github.com/edmundhung/maildog/commit/02bb46ea341990c6dd4abeeb484f41fb560bc8ba)) 63 | * ignore config file for format checking ([665516d](https://github.com/edmundhung/maildog/commit/665516d195a0baa12d8d8dc7b9827dec91284997)) 64 | * semantic release does not works with signing tag ([9004bd0](https://github.com/edmundhung/maildog/commit/9004bd0f6addefaa14825021f592c9afaee0e9ee)) 65 | * ses should stop walking through the ruleset if alias match ([18fdbd6](https://github.com/edmundhung/maildog/commit/18fdbd68715f9c32270cd1e1b55d9313926dbcd7)) 66 | 67 | 68 | ### Features 69 | 70 | * add a destroy workflow to completely remove maildog from aws ([312ddb8](https://github.com/edmundhung/maildog/commit/312ddb8a77134cb411f62626a4e9a8d458863a9d)) 71 | * add experimental schedular lambda for redriving DLQ messages ([695ea14](https://github.com/edmundhung/maildog/commit/695ea147262044440e2620d577c04daf3f94b728)) 72 | * add regular health check based on alarm state ([da82d3e](https://github.com/edmundhung/maildog/commit/da82d3e827a5b7845c1ceace42ab15e371f9275e)) 73 | * config filename should be configurable ([17d0eca](https://github.com/edmundhung/maildog/commit/17d0ecaa0f0181c5ba5e48c4080a83787d0ebac0)) 74 | * disable default retry attempts on lambda functions ([795290d](https://github.com/edmundhung/maildog/commit/795290d62fcf16f5feb78d99accebc6b05e8b619)) 75 | * dispatcher should read bucket name and key prefix from event ([f2d8fe2](https://github.com/edmundhung/maildog/commit/f2d8fe21fe135af07612d8393b5aa7ecd5d26ffd)) 76 | * enable manual redrive through github action ([2adfd9f](https://github.com/edmundhung/maildog/commit/2adfd9fc824af7f45227748c6eb42af865ccc6e0)) 77 | * encrypt config and check into repo ([bddcc2a](https://github.com/edmundhung/maildog/commit/bddcc2a06f3e821cf3fa4c2152c04286b8b7f937)) 78 | * ensure the alarm treating missing data as not breaching ([d0ff264](https://github.com/edmundhung/maildog/commit/d0ff264e86f74fe0781c09cbf46db52006c95c85)) 79 | * ensure unmatched email to be caught by a domain level rule with bounce action ([bad2b06](https://github.com/edmundhung/maildog/commit/bad2b06ee90682930f671970f3692290c0a0a5e9)) 80 | * implement email forwarding based on aws-lambda-ses-forwarder ([99a3a0f](https://github.com/edmundhung/maildog/commit/99a3a0fea5c643a5426dc78d443be26c28920a13)) 81 | * merge validate and analyze workflow to one ([f752ec5](https://github.com/edmundhung/maildog/commit/f752ec5ccc1d8082c5ccc4192456d7fcb3dcd07a)) 82 | * redesign development workflow ([e8ad2f8](https://github.com/edmundhung/maildog/commit/e8ad2f852e4abf0272b5b5b440296ce56f7befaf)) 83 | * redesigned config structure ([c4a556c](https://github.com/edmundhung/maildog/commit/c4a556c97473cb583923fd177d058ae5046c3226)) 84 | * relax conventional commit requirements from git hook ([231c921](https://github.com/edmundhung/maildog/commit/231c921e1f57e4bf98473c688e513af7bbad1069)) 85 | * setup alarm for DLQ ([e67208f](https://github.com/edmundhung/maildog/commit/e67208f5718538fb690b2fbb5af732f4a8f1bc79)) 86 | * use sns to initiate dispatcher lambda function ([88a0d49](https://github.com/edmundhung/maildog/commit/88a0d49014caa78d5c506b6c7cc407b842015409)) 87 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Edmund Hung 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # maildog 2 | 3 | > 🦴 Hosting your own email forwarding service on AWS\ 4 | > 🐶 Managing with Github actions\ 5 | > 🩺 Monitoring and failure recovery included\ 6 | > 🍖 Almost free\* 7 | 8 | ## 👋 Hey, why building this? 9 | 10 | Since I bought my first domain, I am always tempted to use it as my email address. 11 | However, as I am not receiving many emails a day, hosting my mail server or paying for an email hosting service either make it too complicated or costs too much. 12 | All I want is simply a service forwarding emails to one single place. 13 | 14 | I have been happily using [mailgun](https://www.mailgun.com) for years. 15 | As they changed pricing last year, I started looking for a replacement but options are relatively limited. 16 | 17 | After all, I open-sourced `maildog` and is looking for a community that shares the same needs as me. 18 | The project aims to offer an extensible email forwarding solution with minimal maintenance and operational cost. 19 | 20 | ## ☁️ How it works? 21 | 22 | [![MailDog Architecture](docs/architecture.png)](https://app.cloudcraft.co/view/d3b3c7fb-3e31-445c-ae5a-3fd3cf5080d6?key=VbwNoP3q1N5efAlrXGHK2Q&interactive=true&embed=true) 23 | 24 | > Hint: you can click on the diagram for higher resolution 25 | 26 | ## 💸 Pricing 27 | 28 | Yes. While it is **FREE** to use `maildog`, you are still required to pay AWS for the infrastructure setup on the cloud. 29 | To give you a better idea, here is an **estimated** price breakdown based on the following assumption: 30 | 31 | - 10k emails / month or 333 emails / day 32 | - 100KB mail size in average 33 | - Hosted in US West (Oregon), pricing may be slightly different based on the region 34 | - Counted without any [free quota](https://aws.amazon.com/free) 35 | 36 | | Component | Service | Configuration summary | Monthly | Currency | 37 | | ----------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -------- | 38 | | Bucket | S3 Standard | S3 Standard storage (1 GB per month, depends on the retention policy) | 0.080 | USD | 39 | | Mail Server | SES | Email messages received (10000), Average size of email received (100KB), Email messages sent from email client (10000), Data sent from email client (1 GB per month) | 2.120 | USD | 40 | | Mail Feed | Standard SNS topics | DT Inbound: Not selected (0 TB per month), DT Outbound: Not selected (0 TB per month), AWS Lambda (10000 per month), Requests (10000 per month) | 0.010 | USD | 41 | | Dispatcher | AWS Lambda | Number of requests (10000, with no retry attempt) | 0.100 | USD | 42 | | Scheduler | AWS Lambda | Number of requests (< 10000) | 0.100 | USD | 43 | | DLQ | SQS | Standard queue requests (< 10000 per month) | 0.004 | USD | 44 | | | | | **2.434** | USD | 45 | 46 | > As of 30 June 2021, estimated using [AWS Pricing Calculator](https://calculator.aws/#/estimate?id=f5b7c2a46317a99bfb149569d601e7e285504b4c) 47 | 48 | ## 🚨 Limitations 49 | 50 | ### Amazon SES sandbox 51 | 52 | SES restricts new users by placing them in the sandbox. 53 | Depends on your usage, if you would like to forward emails to [non-verified addresses](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html) 54 | or with higher volume, you might want to [move out of the sandbox](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/request-production-access.html) 55 | 56 | ### Maximum numbers of domains/alias allowed 57 | 58 | `maildog` configures SES using a receipt ruleset with a [hard limit](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/quotas.html) of up to 200 rules and 100 recipients per rule. In general, domains that are configured with fallback emails could be set with only 1 rule using wildcard. But for domains without fallback emails, every 100 alias will be count as 1 rule. As a result, you can set up to 20,000 alias if you are configuring only 1 domain even with no fallback emails. 59 | 60 | ### Regions support 61 | 62 | Not every region supports receiving emails with AWS SES. As of 30 June 2021, only 3 regions you can deploy `maildog` on: 63 | 64 | 1. US East (N. Virginia) 65 | 2. US West (Oregon) 66 | 3. Europe (Ireland) 67 | 68 | Please check the [AWS documentation](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/regions.html#region-receive-email) for the latest update. 69 | 70 | ## 📮 Give it a try? 71 | 72 | You can find the installation guide [here](docs/installation.md). The setup might take 10-20 minutes. 73 | -------------------------------------------------------------------------------- /bin/maildog.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from '@aws-cdk/core'; 4 | import * as fs from 'fs'; 5 | import { parse } from 'jsonc-parser'; 6 | import { MailDogStack } from '../lib/maildog-stack'; 7 | 8 | const app = new cdk.App(); 9 | const config = parse(fs.readFileSync('./maildog.config.json').toString()); 10 | 11 | new MailDogStack(app, 'MailDog', { 12 | config, 13 | stackName: `MailDog-${process.env.ENVIRONMENT_NAME}`, 14 | }); 15 | -------------------------------------------------------------------------------- /cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/maildog.ts", 3 | "versionReporting": false, 4 | "context": { 5 | "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, 6 | "@aws-cdk/core:enableStackNameDuplicates": "true", 7 | "aws-cdk:enableDiffNoFail": "true", 8 | "@aws-cdk/core:stackRelativeExports": "true", 9 | "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true, 10 | "@aws-cdk/aws-secretsmanager:parseOwnedSecretName": true, 11 | "@aws-cdk/aws-kms:defaultKeyPolicies": true, 12 | "@aws-cdk/aws-s3:grantWriteWithoutAcl": true, 13 | "@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount": true, 14 | "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, 15 | "@aws-cdk/aws-efs:defaultEncryptionAtRest": true, 16 | "@aws-cdk/aws-lambda:recognizeVersionProps": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/docs/architecture.png -------------------------------------------------------------------------------- /docs/enable-github-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/docs/enable-github-actions.png -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation Guide 2 | 3 | Before start, please make sure you understand the [potential cost](../README.md#-pricing) and the [limitations](../README.md#-limitations). 4 | 5 | > If you need support, feel free to contact [me](mailto:maildog@edmund.dev). 6 | 7 | ## 1. Preparation 8 | 9 | ### 1.1 Decide on one AWS region 10 | 11 | As mentioned in the limitations section, there are only 3 regions that support email receiving at the moment. 12 | 13 | 1. `us-east-1` US East (N. Virginia) 14 | 2. `us-west-2` US West (Oregon) 15 | 3. `eu-west-1` Europe (Ireland) 16 | 17 | Please choose one based on your preference. You will need the corresponding region code whenever you see `[YOUR REGION HERE]` 18 | 19 | ### 1.2 Setup aws-cli v2 20 | 21 | By following the instructions on [AWS](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html), you will need to create a user on AWS IAM with **AdministratorAccess** (or permissions that covers all aws-cli usage below) and configure the credential using the following command: 22 | 23 | ```sh 24 | aws configure 25 | 26 | # AWS Access Key ID [None]: ..... 27 | # AWS Secret Access Key [None]: ..... 28 | # Default region name [None]: ..... 29 | # Default output format [None]: ..... 30 | ``` 31 | 32 | ### 1.3 Setup Github CLI 33 | 34 | Similar to the previous step, just follow the instructions on [Github](https://github.com/cli/cli#installation). Be sure to [authenticate yourself](https://cli.github.com/manual/gh_auth_login) by: 35 | 36 | ```sh 37 | # start interactive setup 38 | gh auth login 39 | ``` 40 | 41 | ## 2. Setting it up 42 | 43 | ### 2.1 Fork 44 | 45 | To begin, please fork the repository and clone it locally. 46 | 47 | ```sh 48 | # Fork and clone it at the same time 49 | gh repo fork edmundhung/maildog --clone 50 | # Or if you would like to create a fork in an organization 51 | # gh repo fork edmundhung/maildog --clone --org name-of-organization 52 | ``` 53 | 54 | ```sh 55 | # From now on, all commands provided assumes the root of the project being your working directory 56 | cd maildog 57 | ``` 58 | 59 | ```sh 60 | # Ensure all `gh` command works against your fork instead of the original repository 61 | export GH_REPO=[YOUR USER/ORGANIZATION NAME]/maildog; 62 | ``` 63 | 64 | ```sh 65 | # Create production branch from main branch [default branch] 66 | # All your changes below should be pushed to the production branch 67 | # The deploy workflow will automatically deploy changes pushed to this branch 68 | git checkout -b production 69 | ``` 70 | 71 | ### 2.2 Configure `maildog` with `maildog.config.json` 72 | 73 | A JSON file with the name `maildog.config.json` is required at the root of the project. 74 | The format as follows: 75 | 76 | 77 | ```jsonc 78 | // This config file support json with comment (jsonc) 79 | // It simply adds support to single line (//) and multi-line comments (/* ... */) on `json` document 80 | { 81 | "domains": { 82 | "exmaple.com": { // your domain here 83 | "fromEmail": "foo", // optional, default: "noreply" 84 | "scanEnabled": false, // optional, default: true, 85 | "tlsEnforced": false, // optional, default: false, 86 | "fallbackEmails": [], // optional, default: [] 87 | "alias": { // required if `fallbackEmails` are not set 88 | "bar": { // result in `bar@exmaple.com` 89 | "description": "Lorem ipsum", // optional, default: "" 90 | "to": ["baz@exmaple.com"] // required 91 | } 92 | } 93 | } 94 | } 95 | } 96 | ``` 97 | 98 | - **fromEmail**: The sender email address used when `maildog` forward the email 99 | - **scanEnabled**: If true, then emails received will be scanned for spam and viruses 100 | - **tlsEnforced**: Specifies whether `maildog` should require that incoming email is delivered over a connection encrypted with Transport Layer Security (TLS). 101 | - **fallbackEmails**: A catch-all / wildcard rule. All emails received on the corresponding domain will be forwarded to these email addresses unless specified in the `alias` section. 102 | - **alias**: The email prefix with a `description` and list of email addresses forwarding `to` 103 | 104 | Here is an [example](../examples/maildog.config.json) config. 105 | 106 | ### 2.3. Verify your domain and email address on AWS SES 107 | 108 | In this step, you might prefer verifying your domain through the AWS console UI as it will guide you through the DNS records that need to be created. But if you are familiar with a DNS zone file, you can generate one using the command below: 109 | 110 | ```sh 111 | DOMAIN=[YOUR DOMAIN HERE]; 112 | REGION=[YOUR REGION HERE]; 113 | sed \ 114 | -e "s|\${Domain}|$DOMAIN|g" \ 115 | -e "s|\${Region}|$REGION|g" \ 116 | -e "s|\${VerificationToken}|$(aws ses verify-domain-identity --domain "$DOMAIN" --output text)|g" \ 117 | -e "s|\${DkimToken\[0\]}|$(aws ses verify-domain-dkim --domain "$DOMAIN" --query DkimTokens[0] --output text)|g" \ 118 | -e "s|\${DkimToken\[1\]}|$(aws ses verify-domain-dkim --domain "$DOMAIN" --query DkimTokens[1] --output text)|g" \ 119 | -e "s|\${DkimToken\[2\]}|$(aws ses verify-domain-dkim --domain "$DOMAIN" --query DkimTokens[2] --output text)|g" \ 120 | ./examples/maildog.zonefile.txt 121 | ``` 122 | 123 | If you are new to AWS SES and you are setting up with the sandbox. You **MUST** verify the email address that you are forwarding to with AWS using the following command: 124 | 125 | ```sh 126 | aws ses verify-email-identity --email-address [YOUR EMAIL HERE] 127 | ``` 128 | 129 | ### 2.4 Setup secrets 130 | 131 | To let Github managing `maildog` for you, there are 4 secrets that need to be set. 132 | 133 | #### 2.4.1 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY 134 | 135 | To deploy maildog, we need to set up an IAM user with **Programmatic access**. 136 | We have prepared an [example](maildog-policy.json) JSON policy for your reference. 137 | You can also create the user and set the corresponding secrets using the command below: 138 | 139 | ```sh 140 | # Create IAM User - `maildog-user` 141 | aws iam create-user --user-name maildog-user 142 | ``` 143 | 144 | ```sh 145 | # Create IAM Policy - `maildog-policy` 146 | aws iam create-policy --policy-name maildog-policy --policy-document file://docs/maildog-policy.json 147 | ``` 148 | 149 | ```sh 150 | # Attach policy `maildog-policy` to user `maildog-user` 151 | aws iam attach-user-policy --user-name maildog-user --policy-arn $(aws iam list-policies --query "Policies[?PolicyName=='maildog-policy'].Arn" --output text) 152 | ``` 153 | 154 | ```sh 155 | # Generate Sign-in credentials for user user `maildog-user` 156 | # Set AccessKeyId and SecretAccessKey as secret 157 | aws iam create-access-key --user-name maildog-user --query "AccessKey.[AccessKeyId,SecretAccessKey]" --output text \ 158 | | tee >(awk '{print $1;}' | gh secret set AWS_ACCESS_KEY_ID) >(awk '{print $2;}' | gh secret set AWS_SECRET_ACCESS_KEY) 159 | 160 | ``` 161 | 162 | #### 2.4.2 AWS_REGION 163 | 164 | You must also provide the region code as a secret for Github to deploy `maildog` to your preferred region: 165 | 166 | ```sh 167 | # Set AWS region as secret 168 | gh secret set AWS_REGION -b"[YOUR REGION HERE]" 169 | ``` 170 | 171 | #### 2.4.3 ENCRYPT_PASSPHRASE 172 | 173 | In order to protect your config detail from the public, it should be encrypted before commit: 174 | 175 | ```sh 176 | # Encrypt maildog.config.json with gpg 177 | # You will be asked to enter a passphrase twice 178 | # A file with the name `maildog.config.json.gpg` will be created 179 | npm run encrypt-config 180 | ``` 181 | 182 | ```sh 183 | # Commit the encrypted config 184 | git add maildog.config.json.gpg && \ 185 | git commit -m "build: configure maildog" 186 | ``` 187 | 188 | ```sh 189 | # Set the passpharse as secret so the workflows can decrypt the config 190 | gh secret set ENCRYPT_PASSPHRASE -b"[PASSPHARSE]" 191 | ``` 192 | 193 | ## 3. Deploy maildog 194 | 195 | ### 3.1 Enable workflows on your forked repository 196 | 197 | ![enable-github-actions-screenshot](enable-github-actions.png) 198 | 199 | Before Github can manage maildog, you need to enable it explicitly. 200 | Please visit the actions tab of your repository and enable it by clicking `I understand my workflows, go ahead and enable them`. 201 | 202 | ### 3.2 Run the Deploy workflow 203 | 204 | Now, everything is ready. All you need is to push the production branch up with the changes you made previously. 205 | The deploy workflow will be triggered automatically whenever you push to the `production` branch: 206 | 207 | ```sh 208 | # Push the production branch 209 | git push --set-upstream origin production 210 | ``` 211 | 212 | ```sh 213 | # Check the deploy workflow status 214 | gh workflow view --web deploy.yaml 215 | ``` 216 | 217 | ### 3.3 Destroy maildog 218 | 219 | You can delete everything deployed easily with the destroy workflow: 220 | 221 | ```sh 222 | # Check the destroy workflow from Github 223 | gh workflow view --web destroy.yaml 224 | ``` 225 | 226 | ### 3.4 Monitor with Health check 227 | 228 | `maildog` sends failed messages to the Dead Letter Queue (DLQ). The health workflow works by monitoring the alarm status configured to the DLQ every hour. You can enable it if needed: 229 | 230 | ```sh 231 | #Enable the health workflow 232 | gh workflow enable health.yaml 233 | ``` 234 | -------------------------------------------------------------------------------- /docs/maildog-policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "Cloudwatch", 6 | "Effect": "Allow", 7 | "Action": "cloudwatch:*", 8 | "Resource": [ 9 | "arn:aws:cloudwatch:*:*:metric-stream/MailDog-*", 10 | "arn:aws:cloudwatch::*:dashboard/MailDog-*", 11 | "arn:aws:cloudwatch:*:*:insight-rule/MailDog-*", 12 | "arn:aws:cloudwatch:*:*:alarm:MailDog-*" 13 | ] 14 | }, 15 | { 16 | "Sid": "SES", 17 | "Effect": "Allow", 18 | "Action": [ 19 | "cloudformation:*", 20 | "cloudwatch:DescribeAlarms", 21 | "s3:ListAllMyBuckets", 22 | "logs:*", 23 | "ses:*" 24 | ], 25 | "Resource": "*" 26 | }, 27 | { 28 | "Sid": "LAMBDA", 29 | "Effect": "Allow", 30 | "Action": "lambda:*", 31 | "Resource": "arn:aws:lambda:*:*:function:MailDog-*" 32 | }, 33 | { 34 | "Sid": "SQS", 35 | "Effect": "Allow", 36 | "Action": "sqs:*", 37 | "Resource": "arn:aws:sqs:*:*:MailDog-*" 38 | }, 39 | { 40 | "Sid": "SNS", 41 | "Effect": "Allow", 42 | "Action": "sns:*", 43 | "Resource": "arn:aws:sns:*:*:MailDog-*" 44 | }, 45 | { 46 | "Sid": "S3", 47 | "Effect": "Allow", 48 | "Action": "s3:*", 49 | "Resource": [ 50 | "arn:aws:s3:::cdktoolkit-stagingbucket-*", 51 | "arn:aws:s3:::maildog-*" 52 | ] 53 | }, 54 | { 55 | "Sid": "IAM", 56 | "Effect": "Allow", 57 | "Action": "iam:*", 58 | "Resource": "arn:aws:iam::*:role/MailDog-*" 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /examples/maildog.config.json: -------------------------------------------------------------------------------- 1 | // This config file support json with comment (jsonc) 2 | // It simply adds support to single line (//) and multi-line comments (/* ... */) on `json` document 3 | { 4 | "domains": { 5 | /* 6 | "exmaple.com": { // your domain here 7 | "fromEmail": "foo", // optional, default: "noreply" 8 | "scanEnabled": false, // optional, default: true, 9 | "tlsEnforced": false, // optional, default: false, 10 | "fallbackEmails": [], // optional, default: [] 11 | "alias": { // required if `fallbackEmails` are not set 12 | "bar": { // result in `bar@exmaple.com` 13 | "description": "Lorem ipsum", // optional, default: "" 14 | "to": ["baz@exmaple.com"] // required 15 | } 16 | } 17 | }, 18 | */ 19 | "maildog.dev": { 20 | "fromEmail": "noreply", 21 | "scanEnabled": true, 22 | "tlsEnforced": true, 23 | "alias": { 24 | "edmund": { 25 | "description": "Author", // For your reference only 26 | "to": ["me@edmund.dev"] 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/maildog.zonefile.txt: -------------------------------------------------------------------------------- 1 | ;; 2 | ;; To complete verification of ${Domain}, 3 | ;; you must add the following records to the domain's DNS settings: 4 | ;; 5 | 6 | ;; CNAME Records 7 | ${DkimToken[0]}._domainkey.${Domain}. 1 IN CNAME ${DkimToken[0]}.dkim.amazonses.com. 8 | ${DkimToken[1]}._domainkey.${Domain}. 1 IN CNAME ${DkimToken[1]}.dkim.amazonses.com. 9 | ${DkimToken[2]}._domainkey.${Domain}. 1 IN CNAME ${DkimToken[2]}.dkim.amazonses.com. 10 | 11 | ;; MX Records 12 | ${Domain}. 1 IN MX 10 inbound-smtp.${Region}.amazonaws.com. 13 | 14 | ;; TXT Records 15 | _amazonses.${Domain}. 1 IN TXT "${VerificationToken}" 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /lib/maildog-stack.dispatcher.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SNSHandler, 3 | SNSEvent, 4 | SESEvent, 5 | SESMessage, 6 | SESReceiptS3Action, 7 | } from 'aws-lambda'; 8 | import LambdaForwarder from 'aws-lambda-ses-forwarder'; 9 | 10 | export interface DispatcherConfig { 11 | fromEmail?: string | null; 12 | forwardMapping: Record; 13 | } 14 | 15 | function isSESMessage(message: any): message is SESMessage { 16 | return ( 17 | typeof message.mail !== 'undefined' && 18 | typeof message.receipt !== 'undefined' 19 | ); 20 | } 21 | 22 | function isSESReceiptS3Action( 23 | action: SESMessage['receipt']['action'], 24 | ): action is SESReceiptS3Action { 25 | return ( 26 | action.type === 'S3' && 27 | typeof action.objectKey !== 'undefined' && 28 | typeof action.bucketName !== 'undefined' 29 | ); 30 | } 31 | 32 | function getSESMessage(event: SNSEvent): SESMessage { 33 | if (event.Records.length !== 1) { 34 | throw new Error( 35 | 'Dispatcher can only handle 1 record at a time; Please verify if the setup is correct', 36 | ); 37 | } 38 | 39 | const [record] = event.Records; 40 | 41 | if (record.EventSource !== 'aws:sns') { 42 | throw new Error( 43 | `Unexpected event source: ${record.EventSource}; Only SNS Event is accepted at the moment`, 44 | ); 45 | } 46 | 47 | const message = JSON.parse(record.Sns.Message); 48 | 49 | if (!isSESMessage(message)) { 50 | throw new Error( 51 | `Unexpected message received: ${record.Sns.Message}; Only SES Message is accepted at the moment`, 52 | ); 53 | } 54 | 55 | return message; 56 | } 57 | 58 | export const handler: SNSHandler = (event, context, callback) => { 59 | let message: SESMessage; 60 | 61 | try { 62 | message = getSESMessage(event); 63 | 64 | if (!isSESReceiptS3Action(message.receipt.action)) { 65 | throw new Error( 66 | 'The event is not triggered by S3 action; Please verify if the setup is correct', 67 | ); 68 | } 69 | } catch (e) { 70 | console.log({ 71 | level: 'error', 72 | message: e.message, 73 | event: JSON.stringify(event), 74 | }); 75 | throw e; 76 | } 77 | 78 | const emailKeyPrefix = message.receipt.action.objectKey.replace( 79 | message.mail.messageId, 80 | '', 81 | ); 82 | const emailBucket = message.receipt.action.bucketName; 83 | const config = (process.env.CONFIG_PER_KEY_PREFIX ?? {}) as Record< 84 | string, 85 | DispatcherConfig 86 | >; 87 | const overrides = { 88 | config: { 89 | ...config[emailKeyPrefix], 90 | allowPlusSign: true, 91 | emailKeyPrefix, 92 | emailBucket, 93 | }, 94 | }; 95 | 96 | // Simulate SES Event so we can utilise aws-lambda-ses-forwarder for now 97 | // Based on documentation from 98 | // https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-notifications-contents.html#receiving-email-notifications-contents-top-level-json-object 99 | const sesEvent: SESEvent = { 100 | Records: [ 101 | { 102 | eventSource: 'aws:ses', 103 | eventVersion: '1.0', 104 | ses: message, 105 | }, 106 | ], 107 | }; 108 | 109 | LambdaForwarder.handler(sesEvent, context, callback, overrides); 110 | }; 111 | -------------------------------------------------------------------------------- /lib/maildog-stack.scheduler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SQSHandler, 3 | SQSEvent, 4 | SQSRecord, 5 | SNSEvent, 6 | SNSEventRecord, 7 | SESMessage, 8 | } from 'aws-lambda'; 9 | import { SQS, SNS } from 'aws-sdk'; 10 | 11 | type Event = SQSEvent | SNSEvent; 12 | 13 | function isSESMessage(message: any): message is SESMessage { 14 | return ( 15 | message && 16 | typeof message.mail !== 'undefined' && 17 | typeof message.receipt !== 'undefined' 18 | ); 19 | } 20 | 21 | function isEvent(event: any): event is Event { 22 | return event && Array.isArray(event.Records); 23 | } 24 | 25 | function isSNSRecord(record: any): record is SNSEventRecord { 26 | return record.EventSource === 'aws:sns'; 27 | } 28 | 29 | function isSQSRecord(record: any): record is SQSRecord { 30 | return record.eventSource === 'aws:sqs'; 31 | } 32 | 33 | function parseRecord(record: SNSEventRecord | SQSRecord): SESMessage | null { 34 | let content: string; 35 | 36 | try { 37 | if (isSNSRecord(record)) { 38 | content = record.Sns.Message; 39 | } else if (isSQSRecord(record)) { 40 | content = record.body; 41 | } else { 42 | throw new Error('Unknown record found'); 43 | } 44 | } catch (e) { 45 | console.log({ 46 | level: 'error', 47 | message: e.message, 48 | record: JSON.stringify(record), 49 | }); 50 | 51 | throw e; 52 | } 53 | 54 | return parseMessage(content); 55 | } 56 | 57 | function parseMessage(content: string): SESMessage | null { 58 | let message; 59 | 60 | try { 61 | message = JSON.parse(content); 62 | } catch (e) { 63 | console.log({ 64 | level: 'error', 65 | message: e.message, 66 | record: JSON.stringify(content), 67 | }); 68 | return null; 69 | } 70 | 71 | if (isSESMessage(message)) { 72 | return message; 73 | } 74 | 75 | if (!isEvent(message) || message.Records.length !== 1) { 76 | console.log({ 77 | level: 'error', 78 | message: 'Unknown content inside event record', 79 | content: JSON.stringify(content), 80 | }); 81 | return null; 82 | } 83 | 84 | return parseRecord(message.Records[0]); 85 | } 86 | 87 | async function getMessages(sqs: SQS): Promise { 88 | let messages: SQS.Message[] = []; 89 | 90 | do { 91 | const result = await sqs 92 | .receiveMessage({ 93 | QueueUrl: process.env.SQS_QUEUE_URL ?? '', 94 | MessageAttributeNames: ['retry'], 95 | MaxNumberOfMessages: 10, 96 | }) 97 | .promise(); 98 | 99 | messages = messages.concat(result.Messages ?? []); 100 | 101 | if (!result.Messages || result.Messages.length < 10) { 102 | break; 103 | } 104 | } while (true); 105 | 106 | console.log({ 107 | level: 'info', 108 | message: `Messages received: ${messages.length}`, 109 | details: JSON.stringify(messages), 110 | }); 111 | 112 | return messages; 113 | } 114 | 115 | async function publishMessages( 116 | sns: SNS, 117 | messages: SQS.Message[], 118 | ): Promise<[SQS.Message[], SQS.Message[]]> { 119 | const successful: SQS.Message[] = []; 120 | const failed: SQS.Message[] = []; 121 | const result = await Promise.allSettled( 122 | messages.map(async (m) => { 123 | if (!m.ReceiptHandle) { 124 | throw new Error('Invalid message received'); 125 | } 126 | 127 | const sesMessage = parseMessage(m.Body ?? ''); 128 | 129 | if (!sesMessage) { 130 | throw new Error('Unknown message found'); 131 | } 132 | 133 | await sns 134 | .publish({ 135 | TopicArn: process.env.SNS_TOPIC_ARN ?? '', 136 | Message: JSON.stringify(sesMessage), 137 | MessageAttributes: { 138 | retry: { 139 | DataType: 'String', 140 | StringValue: ([] as string[]) 141 | .concat( 142 | m.MessageAttributes?.retry?.StringValue ?? [], 143 | new Date().toISOString(), 144 | ) 145 | .join(','), 146 | }, 147 | }, 148 | }) 149 | .promise(); 150 | }), 151 | ); 152 | 153 | try { 154 | for (let i = 0; i < result.length; i++) { 155 | switch (result[i].status) { 156 | case 'fulfilled': 157 | successful.push(messages[i]); 158 | break; 159 | case 'rejected': 160 | failed.push(messages[i]); 161 | break; 162 | } 163 | } 164 | } catch (e) { 165 | console.log({ 166 | level: 'error', 167 | message: `Unknown exception caught after publishing messages: ${e.message}. `, 168 | error: JSON.stringify(e), 169 | result: JSON.stringify(result), 170 | }); 171 | 172 | throw e; 173 | } 174 | 175 | console.log({ 176 | level: 'info', 177 | message: `Messages published - Successful: ${successful.length}, Failed: ${failed.length}`, 178 | successful: JSON.stringify(successful), 179 | failed: JSON.stringify(failed), 180 | }); 181 | 182 | return [successful, failed]; 183 | } 184 | 185 | async function deleteMessages( 186 | sqs: SQS, 187 | messages: SQS.Message[], 188 | ): Promise { 189 | const result = await sqs 190 | .deleteMessageBatch({ 191 | QueueUrl: process.env.SQS_QUEUE_URL ?? '', 192 | Entries: messages.map((m) => ({ 193 | Id: m.MessageId ?? '', 194 | ReceiptHandle: m.ReceiptHandle ?? '', 195 | })), 196 | }) 197 | .promise(); 198 | 199 | if (result.Failed.length > 0) { 200 | console.log({ 201 | level: 'error', 202 | message: 'Delete messages failed', 203 | details: JSON.stringify(result.Failed), 204 | }); 205 | 206 | throw new Error('Delete messages failed'); 207 | } 208 | 209 | console.log({ 210 | level: 'info', 211 | message: `Messages deleted - Successful: ${result.Successful.length}, Failed: ${result.Failed.length}`, 212 | successful: JSON.stringify(result.Successful), 213 | failed: JSON.stringify(result.Failed), 214 | }); 215 | } 216 | 217 | export const handler: SQSHandler = async () => { 218 | const sqs = new SQS(); 219 | const sns = new SNS(); 220 | const messages = await getMessages(sqs); 221 | const [successful, failed] = await publishMessages(sns, messages); 222 | 223 | if (successful.length > 0) { 224 | await deleteMessages(sqs, successful); 225 | } 226 | 227 | if (failed.length > 0) { 228 | throw new Error( 229 | 'Publishing messages from DLQ fails; Please refer to logs for details', 230 | ); 231 | } 232 | }; 233 | -------------------------------------------------------------------------------- /lib/maildog-stack.spam-filter.ts: -------------------------------------------------------------------------------- 1 | import { SESEvent } from 'aws-lambda'; 2 | 3 | // Based on Example 1: Drop spam 4 | // From https://docs.aws.amazon.com/ses/latest/DeveloperGuide/receiving-email-action-lambda-example-functions.html 5 | export async function handler( 6 | event: SESEvent, 7 | ): Promise<{ disposition: 'STOP_RULE_SET' } | null> { 8 | console.log('Spam filter'); 9 | 10 | const sesNotification = event.Records[0].ses; 11 | console.log('SES Notification:\n', JSON.stringify(sesNotification, null, 2)); 12 | 13 | // Check if any spam check failed 14 | if ( 15 | sesNotification.receipt.spfVerdict.status === 'FAIL' || 16 | sesNotification.receipt.dkimVerdict.status === 'FAIL' || 17 | sesNotification.receipt.spamVerdict.status === 'FAIL' || 18 | sesNotification.receipt.virusVerdict.status === 'FAIL' 19 | ) { 20 | console.log('Dropping spam'); 21 | // Stop processing rule set, dropping message 22 | return { disposition: 'STOP_RULE_SET' }; 23 | } else { 24 | return null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/maildog-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from '@aws-cdk/core'; 2 | import * as cloudwatch from '@aws-cdk/aws-cloudwatch'; 3 | import * as iam from '@aws-cdk/aws-iam'; 4 | import * as lambda from '@aws-cdk/aws-lambda-nodejs'; 5 | import * as s3 from '@aws-cdk/aws-s3'; 6 | import * as ses from '@aws-cdk/aws-ses'; 7 | import * as sesActions from '@aws-cdk/aws-ses-actions'; 8 | import * as sns from '@aws-cdk/aws-sns'; 9 | import * as snsSubscriptions from '@aws-cdk/aws-sns-subscriptions'; 10 | import * as sqs from '@aws-cdk/aws-sqs'; 11 | import * as path from 'path'; 12 | import { DispatcherConfig } from './maildog-stack.dispatcher'; 13 | 14 | interface MailDogAliasRule { 15 | description: string; 16 | to: string[]; 17 | } 18 | 19 | interface MailDogDomainRule { 20 | enabled: boolean; 21 | fromEmail: string; 22 | scanEnabled: boolean; 23 | tlsEnforced: boolean; 24 | fallbackEmails: string[]; 25 | alias: Record>; 26 | } 27 | 28 | interface MailDogConfig { 29 | domains: Record>; 30 | } 31 | 32 | interface MailDogStackProps extends cdk.StackProps { 33 | config: MailDogConfig; 34 | } 35 | 36 | export class MailDogStack extends cdk.Stack { 37 | constructor(scope: cdk.Construct, id: string, props: MailDogStackProps) { 38 | super(scope, id, props); 39 | 40 | // The code that defines your stack goes here 41 | const domainRuleEntries = Object.entries(props.config.domains).map< 42 | [string, MailDogDomainRule] 43 | >(([domain, rule]) => [ 44 | domain, 45 | { 46 | enabled: rule.enabled ?? true, 47 | fromEmail: rule.fromEmail ?? 'noreply', 48 | scanEnabled: rule.scanEnabled ?? true, 49 | tlsEnforced: rule.tlsEnforced ?? false, 50 | fallbackEmails: rule.fallbackEmails ?? [], 51 | alias: rule.alias ?? {}, 52 | }, 53 | ]); 54 | const bucket = new s3.Bucket(this, 'Bucket', { 55 | lifecycleRules: [ 56 | { 57 | expiration: cdk.Duration.days(365), 58 | transitions: [ 59 | { 60 | storageClass: s3.StorageClass.INFREQUENT_ACCESS, 61 | transitionAfter: cdk.Duration.days(30), 62 | }, 63 | { 64 | storageClass: s3.StorageClass.GLACIER, 65 | transitionAfter: cdk.Duration.days(90), 66 | }, 67 | ], 68 | }, 69 | ], 70 | }); 71 | const mailFeed = new sns.Topic(this, 'MailFeed'); 72 | const deadLetterQueue = new sqs.Queue(this, 'DeadLetterQueue', { 73 | retentionPeriod: cdk.Duration.days(14), 74 | }); 75 | const alarm = new cloudwatch.Alarm(this, 'MailAlarm', { 76 | metric: deadLetterQueue.metricApproximateNumberOfMessagesVisible({ 77 | statistic: 'Average', 78 | period: cdk.Duration.minutes(5), 79 | }), 80 | threshold: 1, 81 | evaluationPeriods: 1, 82 | treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, 83 | }); 84 | const dispatcher = new lambda.NodejsFunction(this, 'Dispatcher', { 85 | entry: path.resolve(__dirname, './maildog-stack.dispatcher.ts'), 86 | bundling: { 87 | minify: true, 88 | sourceMap: false, 89 | define: { 90 | 'process.env.CONFIG_PER_KEY_PREFIX': JSON.stringify( 91 | Object.fromEntries( 92 | domainRuleEntries.map<[string, DispatcherConfig]>( 93 | ([domain, rule]) => [ 94 | `${domain}/`, 95 | { 96 | fromEmail: `${rule.fromEmail}@${domain}`, 97 | forwardMapping: Object.fromEntries( 98 | Object.entries(rule.alias) 99 | .concat( 100 | rule.fallbackEmails.length > 0 101 | ? [['', { to: rule.fallbackEmails }]] 102 | : [], 103 | ) 104 | .map(([alias, entry]) => [ 105 | `${alias}@${domain}`, 106 | entry.to ?? [], 107 | ]), 108 | ), 109 | }, 110 | ], 111 | ), 112 | ), 113 | ), 114 | }, 115 | }, 116 | timeout: cdk.Duration.seconds(5), 117 | memorySize: 128, 118 | retryAttempts: 0, 119 | deadLetterQueue, 120 | initialPolicy: [ 121 | new iam.PolicyStatement({ 122 | effect: iam.Effect.ALLOW, 123 | resources: ['arn:aws:logs:*:*:*'], 124 | actions: [ 125 | 'logs:CreateLogGroup', 126 | 'logs:CreateLogStream', 127 | 'logs:PutLogEvents', 128 | ], 129 | }), 130 | new iam.PolicyStatement({ 131 | effect: iam.Effect.ALLOW, 132 | resources: ['*'], 133 | actions: ['ses:SendRawEmail'], 134 | }), 135 | new iam.PolicyStatement({ 136 | effect: iam.Effect.ALLOW, 137 | resources: [`${bucket.bucketArn}/*`], 138 | actions: ['s3:GetObject', 's3:PutObject'], 139 | }), 140 | ], 141 | }); 142 | const spamFilter = new lambda.NodejsFunction(this, 'SpamFilter', { 143 | entry: path.resolve(__dirname, './maildog-stack.spam-filter.ts'), 144 | bundling: { 145 | minify: true, 146 | sourceMap: false, 147 | }, 148 | timeout: cdk.Duration.seconds(3), 149 | memorySize: 128, 150 | retryAttempts: 0, 151 | initialPolicy: [ 152 | new iam.PolicyStatement({ 153 | effect: iam.Effect.ALLOW, 154 | resources: ['arn:aws:logs:*:*:*'], 155 | actions: [ 156 | 'logs:CreateLogGroup', 157 | 'logs:CreateLogStream', 158 | 'logs:PutLogEvents', 159 | ], 160 | }), 161 | ], 162 | }); 163 | const ruleset = new ses.ReceiptRuleSet(this, 'ReceiptRuleSet', { 164 | receiptRuleSetName: `${props.stackName ?? 'MailDog'}-ReceiptRuleSet`, 165 | dropSpam: false, // maybe a bug, it is not added as first rule 166 | rules: domainRuleEntries.flatMap(([domain, rule]) => { 167 | const maxRecipientsPerRule = 100; 168 | const recipients = 169 | rule.fallbackEmails.length > 0 170 | ? [domain] 171 | : Object.entries(rule.alias) 172 | .filter(([_, entry]) => { 173 | if ( 174 | typeof entry.to === 'undefined' || 175 | entry.to.length === 0 176 | ) { 177 | console.warn( 178 | '[maildog] Alias with no forwarding email addresses found; It will be disabled if no fallback emails are set', 179 | ); 180 | return false; 181 | } 182 | 183 | return true; 184 | }) 185 | .map(([alias]) => `${alias}@${domain}`); 186 | const rules = recipients 187 | .reduce((chunks, _, i, list) => { 188 | if (i % maxRecipientsPerRule === 0) { 189 | chunks.push(list.slice(i, i + maxRecipientsPerRule)); 190 | } 191 | 192 | return chunks; 193 | }, [] as string[][]) 194 | .map((recipients) => ({ 195 | enabled: rule.enabled, 196 | recipients: recipients, 197 | scanEnabled: rule.scanEnabled, 198 | tlsPolicy: rule.tlsEnforced 199 | ? ses.TlsPolicy.REQUIRE 200 | : ses.TlsPolicy.OPTIONAL, 201 | actions: [ 202 | new sesActions.Lambda({ 203 | invocationType: 204 | sesActions.LambdaInvocationType.REQUEST_RESPONSE, 205 | function: spamFilter, 206 | }), 207 | new sesActions.S3({ 208 | bucket, 209 | objectKeyPrefix: `${domain}/`, 210 | topic: mailFeed, 211 | }), 212 | ], 213 | })); 214 | 215 | return rules; 216 | }), 217 | }); 218 | 219 | new lambda.NodejsFunction(this, 'Scheduler', { 220 | entry: path.resolve(__dirname, './maildog-stack.scheduler.ts'), 221 | bundling: { 222 | minify: true, 223 | sourceMap: false, 224 | }, 225 | environment: { 226 | SQS_QUEUE_URL: deadLetterQueue.queueUrl, 227 | SNS_TOPIC_ARN: mailFeed.topicArn, 228 | }, 229 | timeout: cdk.Duration.seconds(5), 230 | memorySize: 128, 231 | retryAttempts: 0, 232 | deadLetterQueue, 233 | initialPolicy: [ 234 | new iam.PolicyStatement({ 235 | effect: iam.Effect.ALLOW, 236 | resources: ['arn:aws:logs:*:*:*'], 237 | actions: [ 238 | 'logs:CreateLogGroup', 239 | 'logs:CreateLogStream', 240 | 'logs:PutLogEvents', 241 | ], 242 | }), 243 | new iam.PolicyStatement({ 244 | effect: iam.Effect.ALLOW, 245 | resources: [deadLetterQueue.queueArn], 246 | actions: ['sqs:ReceiveMessage', 'sqs:DeleteMessage'], 247 | }), 248 | new iam.PolicyStatement({ 249 | effect: iam.Effect.ALLOW, 250 | resources: [mailFeed.topicArn], 251 | actions: ['sns:Publish'], 252 | }), 253 | ], 254 | }); 255 | 256 | alarm.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); 257 | ruleset.applyRemovalPolicy(cdk.RemovalPolicy.DESTROY); 258 | mailFeed.addSubscription( 259 | new snsSubscriptions.LambdaSubscription(dispatcher, { 260 | deadLetterQueue, 261 | }), 262 | ); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "maildog", 3 | "version": "1.1.2", 4 | "description": "Hosting your own email forwarding service on AWS and manage it with Github Actions", 5 | "bin": { 6 | "email-masking": "bin/email-masking.js" 7 | }, 8 | "scripts": { 9 | "prepare": "npx is-ci || husky install", 10 | "type-checking": "tsc --noEmit", 11 | "format-checking": "prettier --check .", 12 | "encrypt-config": "gpg --symmetric --yes --cipher-algo AES256 maildog.config.json", 13 | "decrypt-config": "gpg --batch --yes --decrypt --output maildog.config.json", 14 | "test": "jest" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/edmundhung/maildog.git" 19 | }, 20 | "author": { 21 | "name": "Edmund Hung", 22 | "email": "me@edmund.dev", 23 | "url": "https://github.com/edmundhung" 24 | }, 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/edmundhung/maildog/issues" 28 | }, 29 | "homepage": "https://github.com/edmundhung/maildog#readme", 30 | "devDependencies": { 31 | "@actions/core": "^1.4.0", 32 | "@actions/github": "^5.0.0", 33 | "@aws-cdk/assert": "^1.108.1", 34 | "@commitlint/cli": "^12.1.4", 35 | "@commitlint/config-conventional": "^12.1.4", 36 | "@semantic-release/changelog": "^5.0.1", 37 | "@semantic-release/git": "^9.0.0", 38 | "@types/aws-lambda": "^8.10.77", 39 | "@types/jest": "^26.0.23", 40 | "@types/node": "^14.17.3", 41 | "commitizen": "^4.2.4", 42 | "cz-conventional-changelog": "^3.3.0", 43 | "execa": "^5.1.1", 44 | "husky": "^6.0.0", 45 | "jest": "^26.6.3", 46 | "lint-staged": "^11.0.0", 47 | "prettier": "^2.3.1", 48 | "semantic-release": "^17.4.3", 49 | "ts-jest": "^26.5.6" 50 | }, 51 | "config": { 52 | "commitizen": { 53 | "path": "cz-conventional-changelog" 54 | } 55 | }, 56 | "lint-staged": { 57 | "*.{ts,js,json,md,tsx,css}": "prettier --write" 58 | }, 59 | "dependencies": { 60 | "@aws-cdk/aws-cloudwatch": "^1.108.1", 61 | "@aws-cdk/aws-iam": "^1.108.1", 62 | "@aws-cdk/aws-lambda-nodejs": "^1.108.1", 63 | "@aws-cdk/aws-s3": "^1.108.1", 64 | "@aws-cdk/aws-ses": "^1.108.1", 65 | "@aws-cdk/aws-ses-actions": "^1.108.1", 66 | "@aws-cdk/aws-sns": "^1.108.1", 67 | "@aws-cdk/aws-sns-subscriptions": "^1.108.1", 68 | "@aws-cdk/core": "^1.108.1", 69 | "aws-cdk": "^1.108.1", 70 | "aws-lambda-ses-forwarder": "^5.0.0", 71 | "aws-sdk": "^2.935.0", 72 | "esbuild": "^0.12.8", 73 | "jsonc-parser": "^3.0.0", 74 | "source-map-support": "^0.5.19", 75 | "ts-node": "^10.0.0", 76 | "typescript": "^4.3.2" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/extensions/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | // Latest stable ECMAScript features 5 | "@babel/preset-env", 6 | { 7 | "useBuiltIns": false, 8 | // Do not transform modules to CJS 9 | "modules": false, 10 | "targets": { 11 | "chrome": "49", 12 | "firefox": "52", 13 | "opera": "36", 14 | "edge": "79" 15 | } 16 | } 17 | ], 18 | "@babel/typescript", 19 | "@babel/react" 20 | ], 21 | "plugins": [ 22 | ["@babel/plugin-proposal-class-properties"], 23 | ["@babel/plugin-transform-destructuring", { 24 | "useBuiltIns": true 25 | }], 26 | ["@babel/plugin-proposal-object-rest-spread", { 27 | "useBuiltIns": true 28 | }], 29 | [ 30 | // Polyfills the runtime needed for async/await and generators 31 | "@babel/plugin-transform-runtime", 32 | { 33 | "helpers": false, 34 | "regenerator": true 35 | } 36 | ] 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /packages/extensions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /packages/extensions/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/packages/extensions/README.md -------------------------------------------------------------------------------- /packages/extensions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@maildog/extensions", 3 | "version": "0.1.0", 4 | "description": "Manage your maildog with web extensions", 5 | "license": "MIT", 6 | "scripts": { 7 | "chrome": "TARGET_BROWSER=chrome webpack", 8 | "firefox": "TARGET_BROWSER=firefox webpack", 9 | "opera": "TARGET_BROWSER=opera webpack", 10 | "build": "export NODE_ENV=production; npm run chrome & npm run firefox & npm run opera & wait" 11 | }, 12 | "author": { 13 | "name": "Edmund Hung", 14 | "email": "me@edmund.dev", 15 | "url": "https://github.com/edmundhung" 16 | }, 17 | "dependencies": { 18 | "@babel/runtime": "^7.14.0", 19 | "openpgp": "^5.0.0-5", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2", 22 | "react-query": "^3.19.0", 23 | "tailwindcss": "^2.2.4", 24 | "webextension-polyfill-ts": "^0.25.0", 25 | "xstate": "^4.23.1" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.14.3", 29 | "@babel/eslint-parser": "^7.12.16", 30 | "@babel/plugin-proposal-class-properties": "^7.13.0", 31 | "@babel/plugin-proposal-object-rest-spread": "^7.14.2", 32 | "@babel/plugin-transform-destructuring": "^7.13.17", 33 | "@babel/plugin-transform-runtime": "^7.14.3", 34 | "@babel/preset-env": "^7.14.2", 35 | "@babel/preset-react": "^7.13.13", 36 | "@babel/preset-typescript": "^7.13.0", 37 | "@types/react": "^17.0.6", 38 | "@types/react-dom": "^17.0.5", 39 | "@types/webpack": "^4.41.29", 40 | "autoprefixer": "^10.3.1", 41 | "babel-loader": "^8.2.2", 42 | "clean-webpack-plugin": "^3.0.0", 43 | "copy-webpack-plugin": "^6.4.1", 44 | "css-loader": "^5.2.5", 45 | "filemanager-webpack-plugin": "^3.1.1", 46 | "html-webpack-plugin": "^4.5.2", 47 | "mini-css-extract-plugin": "^1.6.0", 48 | "optimize-css-assets-webpack-plugin": "^5.0.6", 49 | "postcss": "^8.3.5", 50 | "postcss-loader": "^4.3.0", 51 | "resolve-url-loader": "^3.1.3", 52 | "terser-webpack-plugin": "^4.2.3", 53 | "typescript": "^4.1.5", 54 | "webpack": "^4.46.0", 55 | "webpack-cli": "^4.7.0", 56 | "webpack-extension-reloader": "^1.1.4", 57 | "wext-manifest-loader": "^2.3.0", 58 | "wext-manifest-webpack-plugin": "^1.2.1" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/extensions/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/extensions/public/assets/icons/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/packages/extensions/public/assets/icons/favicon-128.png -------------------------------------------------------------------------------- /packages/extensions/public/assets/icons/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/packages/extensions/public/assets/icons/favicon-16.png -------------------------------------------------------------------------------- /packages/extensions/public/assets/icons/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/packages/extensions/public/assets/icons/favicon-32.png -------------------------------------------------------------------------------- /packages/extensions/public/assets/icons/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundhung/maildog/f17fad7f227d4b3bd6f0e217ed3642e37c1fe5fc/packages/extensions/public/assets/icons/favicon-48.png -------------------------------------------------------------------------------- /packages/extensions/public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/extensions/public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/extensions/src/background/helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | encrypt, 3 | decrypt, 4 | createMessage, 5 | readMessage, 6 | } from 'openpgp/lightweight'; 7 | import { browser, Tabs } from 'webextension-polyfill-ts'; 8 | import { copyText } from '../utils'; 9 | import { Session, Config } from '../types'; 10 | 11 | export async function request(path: string, init?: RequestInit): Promise { 12 | const response = await fetch( 13 | `${process.env.WEB_URL ?? 'http://localhost:3000'}${path}`, 14 | init, 15 | ); 16 | const body = await response.json(); 17 | const { data, error } = body ?? {}; 18 | 19 | if (!response.ok) { 20 | throw new Error(error?.message ?? response.statusText); 21 | } 22 | 23 | return data; 24 | } 25 | 26 | export async function getOptions(): Promise { 27 | const { repos } = await request<{ repos: string[] }>('/api/session'); 28 | 29 | return repos; 30 | } 31 | 32 | export async function decryptConfig( 33 | encryptedConfig: string, 34 | passphrase: string, 35 | ): Promise { 36 | const encryptedMessage = await readMessage({ 37 | armoredMessage: encryptedConfig, 38 | }); 39 | const { data } = await decrypt({ 40 | message: encryptedMessage, 41 | passwords: [passphrase], 42 | }); 43 | 44 | return JSON.parse(data); 45 | } 46 | 47 | export async function encryptConfig( 48 | config: any, 49 | passphrase: string, 50 | ): Promise { 51 | const message = await createMessage({ 52 | text: JSON.stringify(config, null, 2), 53 | }); 54 | const encrypted = await encrypt({ 55 | message, 56 | passwords: [passphrase], 57 | format: 'armored', 58 | }); 59 | 60 | return encrypted; 61 | } 62 | 63 | export async function getConfig( 64 | repository: string, 65 | passphrase: string, 66 | ): Promise<[any, string]> { 67 | const file = await request<{ 68 | encoding: string; 69 | content: string; 70 | sha: string; 71 | }>(`/api/${repository}/config`); 72 | 73 | if (file.encoding !== 'base64') { 74 | throw new Error(`Unexpected file encoding: ${file.encoding} returned`); 75 | } 76 | 77 | const config = await decryptConfig(atob(file.content), passphrase); 78 | 79 | return [config, file.sha]; 80 | } 81 | 82 | export async function saveConfig( 83 | repository: string, 84 | passphrase: string, 85 | config: any, 86 | sha: string, 87 | ): Promise { 88 | const encryptedConfig = await encryptConfig(config, passphrase); 89 | const updatedSha = await request(`/api/${repository}/config`, { 90 | method: 'PUT', 91 | headers: { 92 | 'Content-Type': 'application/json', 93 | }, 94 | body: JSON.stringify({ 95 | sha, 96 | content: btoa(encryptedConfig), 97 | }), 98 | }); 99 | 100 | return updatedSha; 101 | } 102 | 103 | export async function openPageInBackground(path: string): Promise { 104 | const backgroundTab = await browser.tabs.create({ 105 | url: `${process.env.WEB_URL ?? 'http://localhost:3000'}${path}`, 106 | active: false, 107 | }); 108 | 109 | return new Promise((resolve) => { 110 | function listener( 111 | tabId: number, 112 | changeInfo: Tabs.OnUpdatedChangeInfoType, 113 | tab: Tabs.Tab, 114 | ): void { 115 | if (tabId !== backgroundTab.id || changeInfo.status !== 'complete') { 116 | return; 117 | } 118 | 119 | resolve(tab); 120 | browser.tabs.onUpdated.removeListener(listener); 121 | } 122 | 123 | browser.tabs.onUpdated.addListener(listener); 124 | }); 125 | } 126 | 127 | export async function login(): Promise { 128 | const tab = await openPageInBackground('/login'); 129 | 130 | if (!tab.url) { 131 | browser.tabs.update(tab.id, { active: true }); 132 | return; 133 | } 134 | 135 | browser.tabs.remove(tab.id); 136 | } 137 | 138 | export async function logout(): Promise { 139 | const tab = await openPageInBackground('/logout'); 140 | 141 | browser.tabs.remove(tab.id); 142 | } 143 | 144 | export async function updateContextMenu( 145 | emailsByDomain: Record, 146 | onAssignNewEmail: (domain: string) => void, 147 | ): Promise { 148 | await browser.contextMenus.removeAll(); 149 | 150 | const list = Object.entries(emailsByDomain); 151 | 152 | if (list.length === 0) { 153 | return; 154 | } 155 | 156 | if (list.length > 1) { 157 | browser.contextMenus.create({ 158 | id: 'maildog', 159 | title: 'maildog', 160 | contexts: ['all'], 161 | }); 162 | } 163 | 164 | for (const [domain, emails] of list) { 165 | if (list.length > 1) { 166 | browser.contextMenus.create({ 167 | id: `maildog-${domain}`, 168 | parentId: 'maildog', 169 | title: domain, 170 | contexts: ['all'], 171 | }); 172 | } else { 173 | browser.contextMenus.create({ 174 | id: `maildog-${domain}`, 175 | title: `maildog (${domain})`, 176 | contexts: ['all'], 177 | }); 178 | } 179 | 180 | emails.forEach((email) => { 181 | browser.contextMenus.create({ 182 | id: `maildog-${domain}-${email}`, 183 | parentId: `maildog-${domain}`, 184 | title: email, 185 | contexts: ['all'], 186 | onclick: () => copyText(email), 187 | }); 188 | }); 189 | 190 | browser.contextMenus.create({ 191 | id: `maildog-${domain}-seperator`, 192 | parentId: `maildog-${domain}`, 193 | type: 'separator', 194 | contexts: ['all'], 195 | }); 196 | 197 | browser.contextMenus.create({ 198 | id: `maildog-${domain}-new`, 199 | parentId: `maildog-${domain}`, 200 | title: 'Generate email address', 201 | contexts: ['all'], 202 | onclick: () => onAssignNewEmail(domain), 203 | }); 204 | } 205 | } 206 | 207 | export function generateNewEmail(domain: string): string { 208 | return `${Math.random().toString(36).slice(2)}@${domain}`; 209 | } 210 | 211 | export function getSession(context: Context): Session { 212 | return { 213 | repository: context.repository, 214 | configByDomain: deriveConfigByDomain(context.config), 215 | emails: lookupEmails(context.config, context.activeTabUrl), 216 | options: context.options, 217 | }; 218 | } 219 | 220 | export function matchHost(host: string, website: string | undefined): boolean { 221 | if (!website) { 222 | return false; 223 | } 224 | 225 | try { 226 | return new URL(website).host === host; 227 | } catch (e) { 228 | return false; 229 | } 230 | } 231 | 232 | export function lookupEmails(config: any, currentUrl: string | null): string[] { 233 | if (config === null || currentUrl === null) { 234 | return []; 235 | } 236 | 237 | const url = new URL(currentUrl); 238 | 239 | return Object.entries(config.domains).flatMap(([domain, config]) => 240 | Object.entries(config.alias ?? {}) 241 | .filter(([_, rule]) => matchHost(url.host, rule.website)) 242 | .map(([prefix]) => `${prefix}@${domain}`), 243 | ); 244 | } 245 | 246 | export function getEmailsByDomain(domains: string[], emails: string[]) { 247 | return emails.reduce((result, email) => { 248 | const domain = email.slice(email.indexOf('@') + 1); 249 | const emails = result[domain] ?? []; 250 | 251 | emails.push(email); 252 | result[domain] = emails; 253 | 254 | return result; 255 | }, Object.fromEntries(domains.map<[string, string[]]>((domain) => [domain, []]))); 256 | } 257 | 258 | export function deriveConfigByDomain( 259 | config: any, 260 | ): Record | null { 261 | if (config === null) { 262 | return null; 263 | } 264 | 265 | return Object.fromEntries( 266 | Object.keys(config.domains).map<[string, Config]>((domain) => [ 267 | domain, 268 | { recipents: [] }, 269 | ]), 270 | ); 271 | } 272 | 273 | export async function getActiveTab(): Promise { 274 | const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 275 | 276 | return tab; 277 | } 278 | -------------------------------------------------------------------------------- /packages/extensions/src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { interpret, State } from 'xstate'; 2 | import { browser } from 'webextension-polyfill-ts'; 3 | import { Message } from '../types'; 4 | import { 5 | updateContextMenu, 6 | lookupEmails, 7 | getEmailsByDomain, 8 | getSession, 9 | generateNewEmail, 10 | } from './helpers'; 11 | import machine, { Context } from './machine'; 12 | import { copyText } from '../utils'; 13 | 14 | function main() { 15 | const service = interpret(machine) 16 | .start() 17 | .onChange(async (context) => { 18 | if (context.config === null) { 19 | return; 20 | } 21 | 22 | const domains = Object.keys(context.config.domains); 23 | const emails = lookupEmails(context.config, context.activeTabUrl); 24 | const emailsByDomain = getEmailsByDomain(domains, emails); 25 | 26 | await browser.browserAction.setBadgeText({ 27 | text: emails.length > 0 ? `${emails.length}` : '', 28 | }); 29 | 30 | await updateContextMenu(emailsByDomain, (domain) => { 31 | const email = generateNewEmail(domain); 32 | 33 | service.send({ 34 | type: 'ASSIGN_NEW_EMAIL', 35 | email, 36 | }); 37 | 38 | copyText(email); 39 | }); 40 | }); 41 | 42 | browser.browserAction.setBadgeBackgroundColor({ color: '#537780' }); 43 | 44 | browser.tabs.onActivated.addListener(async (activeInfo) => { 45 | const tab = await browser.tabs.get(activeInfo.tabId); 46 | 47 | if (!tab.url) { 48 | return; 49 | } 50 | 51 | service.send({ 52 | type: 'ACTIVATE_TAB', 53 | tabId: activeInfo.tabId, 54 | tabUrl: tab.url, 55 | }); 56 | }); 57 | 58 | browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 59 | if (!tab.url) { 60 | return; 61 | } 62 | 63 | service.send({ 64 | type: 'UPDATE_TAB', 65 | tabId, 66 | tabUrl: tab.url, 67 | }); 68 | }); 69 | 70 | browser.runtime.onMessage.addListener((message: Message) => { 71 | return new Promise((resolve, reject) => { 72 | switch (message.type) { 73 | case 'GET_SESSION': 74 | if (service.state.matches('authenticated')) { 75 | resolve(getSession(service.state.context)); 76 | } else { 77 | resolve(null); 78 | } 79 | break; 80 | case 'ASSIGN_NEW_EMAIL': 81 | const email = generateNewEmail(message.domain); 82 | const handleAssign = (state: State) => { 83 | if (state.matches('authenticated.unlocked.idle')) { 84 | service.off(handleAssign); 85 | resolve(email); 86 | } 87 | }; 88 | 89 | service.onTransition(handleAssign).send({ 90 | type: 'ASSIGN_NEW_EMAIL', 91 | email, 92 | }); 93 | break; 94 | case 'UNLOCK': 95 | const handleUnlock = (state: State) => { 96 | if (state.matches('authenticated.unlocked')) { 97 | service.off(handleUnlock); 98 | resolve(); 99 | } 100 | }; 101 | 102 | service.onTransition(handleUnlock).send({ 103 | type: 'UNLOCK', 104 | repository: message.repository, 105 | passphrase: message.passphrase, 106 | }); 107 | break; 108 | case 'LOGIN': 109 | const handleLogin = (state: State) => { 110 | if (state.matches('authenticated')) { 111 | service.off(handleLogin); 112 | resolve(); 113 | } 114 | }; 115 | 116 | service.onTransition(handleLogin).send('LOGIN'); 117 | break; 118 | case 'LOGOUT': 119 | const handleLogout = (state: State) => { 120 | if (state.matches('unauthenticated')) { 121 | service.off(handleLogout); 122 | resolve(); 123 | } 124 | }; 125 | 126 | service.onTransition(handleLogout).send('LOGOUT'); 127 | break; 128 | default: 129 | reject( 130 | new Error(`Unknown message received: ${JSON.stringify(message)}`), 131 | ); 132 | break; 133 | } 134 | }); 135 | }); 136 | } 137 | 138 | main(); 139 | -------------------------------------------------------------------------------- /packages/extensions/src/background/machine.ts: -------------------------------------------------------------------------------- 1 | import { MachineConfig, createMachine, assign } from 'xstate'; 2 | import { LOGIN_EVENT, LOGOUT_EVENT, UNLOCK_EVENT } from '../types'; 3 | import { copyText } from '../utils'; 4 | import { 5 | login, 6 | logout, 7 | getOptions, 8 | getConfig, 9 | saveConfig, 10 | getActiveTab, 11 | } from './helpers'; 12 | 13 | export interface Context { 14 | activeTabId: number | null; 15 | activeTabUrl: string | null; 16 | repository: string | null; 17 | options: string[]; 18 | passphrase: string | null; 19 | config: any | null; 20 | sha: string | null; 21 | } 22 | 23 | interface ACTIVATE_TAB_EVENT { 24 | type: 'ACTIVATE_TAB'; 25 | tabId: number; 26 | tabUrl: string; 27 | } 28 | 29 | interface UPDATE_TAB_EVENT { 30 | type: 'UPDATE_TAB'; 31 | tabId: number; 32 | tabUrl: string; 33 | } 34 | 35 | interface ASSIGN_NEW_EMAIL_EVENT { 36 | type: 'ASSIGN_NEW_EMAIL'; 37 | email: string; 38 | } 39 | 40 | export type Event = 41 | | ACTIVATE_TAB_EVENT 42 | | UPDATE_TAB_EVENT 43 | | LOGIN_EVENT 44 | | LOGOUT_EVENT 45 | | UNLOCK_EVENT 46 | | ASSIGN_NEW_EMAIL_EVENT; 47 | 48 | const machineConfig: MachineConfig = { 49 | id: 'background', 50 | context: { 51 | activeTabId: null, 52 | activeTabUrl: null, 53 | repository: null, 54 | options: [], 55 | passphrase: null, 56 | config: null, 57 | sha: null, 58 | }, 59 | on: { 60 | ACTIVATE_TAB: { 61 | actions: assign({ 62 | activeTabId: (context, event) => event.tabId, 63 | activeTabUrl: (context, event) => event.tabUrl, 64 | }), 65 | }, 66 | UPDATE_TAB: { 67 | actions: assign({ 68 | activeTabUrl: (context, event) => 69 | event.tabId === context.activeTabId 70 | ? event.tabUrl 71 | : context.activeTabUrl, 72 | }), 73 | }, 74 | }, 75 | initial: 'initializing', 76 | states: { 77 | unauthenticated: { 78 | on: { LOGIN: 'loggingIn' }, 79 | }, 80 | loggingIn: { 81 | invoke: { 82 | id: 'logging-in', 83 | src: () => login(), 84 | onDone: { 85 | target: 'initializing', 86 | }, 87 | onError: 'unauthenticated', 88 | }, 89 | }, 90 | initializing: { 91 | invoke: { 92 | id: 'authenticate', 93 | src: async () => { 94 | const options = await getOptions(); 95 | const tab = await getActiveTab(); 96 | 97 | return { options, tab }; 98 | }, 99 | onDone: { 100 | target: 'authenticated', 101 | actions: assign({ 102 | options: (_, event) => event.data?.options ?? [], 103 | activeTabId: (_, event) => event.data?.tab?.id, 104 | activeTabUrl: (_, event) => event.data?.tab?.url, 105 | }), 106 | }, 107 | onError: 'unauthenticated', 108 | }, 109 | }, 110 | loggingOut: { 111 | invoke: { 112 | id: 'logging-out', 113 | src: () => logout(), 114 | onDone: 'unauthenticated', 115 | onError: 'unauthenticated', 116 | }, 117 | }, 118 | authenticated: { 119 | on: { 120 | LOGOUT: { 121 | target: 'loggingOut', 122 | actions: assign({ 123 | repository: null, 124 | options: [], 125 | passphrase: null, 126 | config: null, 127 | sha: null, 128 | }), 129 | }, 130 | }, 131 | initial: 'locked', 132 | states: { 133 | locked: { 134 | on: { 135 | UNLOCK: { 136 | target: 'unlocking', 137 | actions: assign({ 138 | repository: (_, event) => event.repository, 139 | passphrase: (_, event) => event.passphrase, 140 | config: null, 141 | sha: null, 142 | }), 143 | }, 144 | }, 145 | }, 146 | unlocked: { 147 | on: { 148 | UNLOCK: { 149 | target: 'unlocking', 150 | actions: assign({ 151 | repository: (_, event) => event.repository, 152 | passphrase: (_, event) => event.passphrase, 153 | config: null, 154 | sha: null, 155 | }), 156 | }, 157 | }, 158 | initial: 'idle', 159 | states: { 160 | idle: { 161 | on: { 162 | ASSIGN_NEW_EMAIL: { 163 | target: 'updating', 164 | actions: assign({ 165 | config: (context, { email }) => { 166 | const [alias, domain] = email.split('@'); 167 | const oldConfig = context.config.domains[domain]; 168 | const newConfig = { 169 | ...oldConfig, 170 | alias: { 171 | ...oldConfig.alias, 172 | [alias]: { 173 | description: `Generated from web extension at ${new Date().toLocaleString()}`, 174 | website: context.activeTabUrl, 175 | }, 176 | }, 177 | }; 178 | 179 | const result = { 180 | ...context.config, 181 | domains: { 182 | ...context.config.domains, 183 | [domain]: newConfig, 184 | }, 185 | }; 186 | 187 | return result; 188 | }, 189 | }), 190 | }, 191 | }, 192 | }, 193 | updating: { 194 | invoke: { 195 | id: 'updating', 196 | src: (context) => 197 | saveConfig( 198 | context.repository, 199 | context.passphrase, 200 | context.config, 201 | context.sha, 202 | ), 203 | onDone: { 204 | target: 'idle', 205 | actions: assign({ 206 | sha: (_, event) => event.data, 207 | }), 208 | }, 209 | onError: { 210 | target: 'idle', 211 | }, 212 | }, 213 | }, 214 | }, 215 | }, 216 | unlocking: { 217 | invoke: { 218 | id: 'unlock', 219 | src: (context) => getConfig(context.repository, context.passphrase), 220 | onDone: { 221 | target: 'unlocked', 222 | actions: assign((_, event) => { 223 | const [config, sha] = event.data; 224 | 225 | return { 226 | config, 227 | sha, 228 | }; 229 | }), 230 | }, 231 | onError: { 232 | target: 'locked', 233 | actions: assign({ 234 | repository: null, 235 | passphrase: null, 236 | }), 237 | }, 238 | }, 239 | }, 240 | }, 241 | }, 242 | }, 243 | }; 244 | 245 | export default createMachine(machineConfig); 246 | -------------------------------------------------------------------------------- /packages/extensions/src/components/Options.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const Options: React.FC = () => { 4 | return ( 5 |
6 |
7 |

8 | 9 |
10 | 18 |

19 |

20 | 24 | 25 |

cool cool cool

26 |

27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Options; 33 | -------------------------------------------------------------------------------- /packages/extensions/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "MailDog WebExtension", 4 | "version": "0.0.0", 5 | 6 | "icons": { 7 | "16": "assets/icons/favicon-16.png", 8 | "32": "assets/icons/favicon-32.png", 9 | "48": "assets/icons/favicon-48.png", 10 | "128": "assets/icons/favicon-128.png" 11 | }, 12 | "description": "Manage your maildog on the browser, anytime.", 13 | "homepage_url": "https://maildog.dev", 14 | "short_name": "maildog", 15 | 16 | "permissions": [ 17 | "tabs", 18 | "storage", 19 | "contextMenus", 20 | "clipboardWrite", 21 | "https://maildog.dev/*", 22 | "http://localhost:3000/*" 23 | ], 24 | 25 | "incognito": "split", 26 | "content_security_policy": "script-src 'self'; object-src 'self'", 27 | 28 | "__chrome|firefox__author": "edmundhung", 29 | "__opera__developer": { 30 | "name": "edmundhung" 31 | }, 32 | 33 | "__firefox__applications": { 34 | "gecko": { 35 | "id": "{}" 36 | } 37 | }, 38 | 39 | "__chrome__minimum_chrome_version": "49", 40 | "__opera__minimum_opera_version": "36", 41 | 42 | "browser_action": { 43 | "default_popup": "popup.html", 44 | "default_icon": { 45 | "16": "assets/icons/favicon-16.png", 46 | "32": "assets/icons/favicon-32.png", 47 | "48": "assets/icons/favicon-48.png", 48 | "128": "assets/icons/favicon-128.png" 49 | }, 50 | "default_title": "maildog", 51 | "__chrome|opera__chrome_style": false, 52 | "__firefox__browser_style": false 53 | }, 54 | 55 | "background": { 56 | "scripts": ["js/background.bundle.js"], 57 | "__chrome|opera__persistent": true 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/extensions/src/options.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Options from './components/Options'; 5 | 6 | ReactDOM.render(, document.getElementById('options-root')); 7 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/LoginScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | isLoading: boolean; 5 | onLogin: () => void; 6 | onLearnMore: () => void; 7 | } 8 | 9 | function LoginScreen({ 10 | isLoading, 11 | onLogin, 12 | onLearnMore, 13 | }: Props): React.ReactElement { 14 | return ( 15 |
16 |
17 | logo 24 |
maildog
25 |
26 |
27 |
28 | 35 |
36 |
37 | 44 |
45 |
46 |
47 | ); 48 | } 49 | 50 | export default LoginScreen; 51 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/MainScreen.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { Config } from '../types'; 3 | 4 | interface MainScreenProps { 5 | repository: string; 6 | configByDomain: Record | null; 7 | emails: string[]; 8 | onNavigate: () => void; 9 | onNewEmailRequested: (domain: string) => void; 10 | onEmailClicked: (email: string) => void; 11 | onPassphraseProvided: (passphrase: string) => void; 12 | onUpdate: (config: Config) => void; 13 | } 14 | 15 | function MainScreen({ 16 | repository, 17 | configByDomain, 18 | emails, 19 | onNavigate, 20 | onNewEmailRequested, 21 | onEmailClicked, 22 | onPassphraseProvided, 23 | onOptionUpdate, 24 | }: Props): React.ReactElement { 25 | const ref = useRef(); 26 | const handleSubmit = (e: React.FormEvent) => { 27 | e.preventDefault(); 28 | 29 | const passpharse = ref.current?.value; 30 | 31 | if (!passpharse) { 32 | return; 33 | } 34 | 35 | onPassphraseProvided(passpharse); 36 | }; 37 | 38 | return ( 39 |
40 |
41 |

{repository}

42 | 58 |
59 | {configByDomain === null ? ( 60 |
61 |
62 | 70 |
71 |
72 |
73 | 79 |
80 |
81 | 88 |
89 |
90 |
91 | ) : ( 92 | <> 93 |
    94 | {Object.keys(configByDomain).map((domain) => ( 95 |
  • 96 | 97 | {domain} 98 | 99 |
      100 |
    • 101 | 108 |
    • 109 | {emails 110 | .filter((address) => address.endsWith(`@${domain}`)) 111 | .map((email) => ( 112 |
    • 113 | 120 |
    • 121 | ))} 122 |
    123 |
  • 124 | ))} 125 |
126 | 127 | )} 128 |
129 | ); 130 | } 131 | 132 | export default MainScreen; 133 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/NavigationScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface NavigationScreenProps { 4 | repository: string | null; 5 | options: string[]; 6 | onSelect: (repo: string) => void; 7 | onInstall: () => void; 8 | onLogout: () => void; 9 | } 10 | 11 | function NavigationScreen({ 12 | repository, 13 | options, 14 | onSelect, 15 | onInstall, 16 | onLogout, 17 | }: Props): React.ReactElement { 18 | return ( 19 |
20 |
21 |

Select a repository

22 |
23 |
24 |
25 |
    26 | {options.slice(0, 5).map((option) => ( 27 |
  • 28 | 39 |
  • 40 | ))} 41 |
42 |
43 | 50 | 57 |
58 |
59 | ); 60 | } 61 | 62 | export default NavigationScreen; 63 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/Popup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useQuery, useMutation, useQueryClient } from 'react-query'; 3 | import { browser, Tabs } from 'webextension-polyfill-ts'; 4 | import LoginScreen from './LoginScreen'; 5 | import NavigationScreen from './NavigationScreen'; 6 | import MainScreen from './MainScreen'; 7 | import * as api from './api'; 8 | import { Status } from '../types'; 9 | import { copyText } from '../utils'; 10 | 11 | async function showInstallationPage(): Promise { 12 | await browser.tabs.create({ 13 | url: 'https://github.com/apps/maildog-bot/installations/new', 14 | }); 15 | } 16 | 17 | async function showOfficialWebsite(): Promise { 18 | await browser.tabs.create({ url: 'https://github.com/edmundhung/maildog' }); 19 | } 20 | 21 | function Popup(): React.ReactElement { 22 | const [repository, setRepository] = useState(null); 23 | const [shouldShowMenu, setShowMenu] = useState(false); 24 | const queryClient = useQueryClient(); 25 | const { data, isFetched, refetch } = useQuery({ 26 | queryKey: 'GET_SESSION', 27 | queryFn: api.getSession, 28 | onSuccess(session) { 29 | if (session === null) { 30 | return; 31 | } 32 | 33 | setRepository((current) => { 34 | if (current !== null) { 35 | return current; 36 | } 37 | 38 | return session.repository ?? session.options[0] ?? null; 39 | }); 40 | }, 41 | }); 42 | const unlock = useMutation({ 43 | mutationFn: (passphrase: string) => api.unlock(repository, passphrase), 44 | onSuccess() { 45 | queryClient.invalidateQueries('GET_SESSION'); 46 | }, 47 | }); 48 | const copyEmail = useMutation({ 49 | mutationFn: copyText, 50 | onSuccess() { 51 | window.close(); 52 | }, 53 | }); 54 | const assignNewEmail = useMutation({ 55 | mutationFn: api.assignNewEmail, 56 | onSuccess(email) { 57 | queryClient.invalidateQueries('GET_SESSION'); 58 | copyEmail.mutate(email); 59 | }, 60 | }); 61 | const login = useMutation({ 62 | mutationFn: api.login, 63 | onSuccess() { 64 | queryClient.invalidateQueries('GET_SESSION'); 65 | }, 66 | }); 67 | const logout = useMutation({ 68 | mutationFn: api.logout, 69 | onSuccess() { 70 | queryClient.invalidateQueries('GET_SESSION'); 71 | }, 72 | }); 73 | 74 | const shouldAuthenitcate = isFetched && data === null; 75 | const isLoading = 76 | !isFetched || login.isLoading || logout.isLoading || unlock.isLoading; 77 | 78 | if (isLoading || shouldAuthenitcate) { 79 | return ( 80 | 85 | ); 86 | } 87 | 88 | if (shouldShowMenu) { 89 | return ( 90 | { 94 | setRepository(repository); 95 | setShowMenu(false); 96 | }} 97 | onInstall={showInstallationPage} 98 | onLogout={logout.mutate} 99 | /> 100 | ); 101 | } 102 | 103 | return ( 104 | setShowMenu(true)} 111 | onNewEmailRequested={assignNewEmail.mutate} 112 | onPassphraseProvided={unlock.mutate} 113 | onEmailClicked={copyEmail.mutate} 114 | /> 115 | ); 116 | } 117 | 118 | export default Popup; 119 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/api.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts'; 2 | import { 3 | Session, 4 | GET_SESSION_EVENT, 5 | LOGIN_EVENT, 6 | LOGOUT_EVENT, 7 | UNLOCK_EVENT, 8 | ASSIGN_NEW_EMAIL_EVENT, 9 | } from '../types'; 10 | 11 | export async function getSession(): Promise { 12 | const message: GET_SESSION_EVENT = { 13 | type: 'GET_SESSION', 14 | }; 15 | const status = await browser.runtime.sendMessage(message); 16 | 17 | return status; 18 | } 19 | 20 | export async function login(): Promise { 21 | const message: LOGIN_EVENT = { 22 | type: 'LOGIN', 23 | }; 24 | 25 | await browser.runtime.sendMessage(message); 26 | } 27 | 28 | export async function logout(): Promise { 29 | const message: LOGOUT_EVENT = { 30 | type: 'LOGOUT', 31 | }; 32 | 33 | await browser.runtime.sendMessage(message); 34 | } 35 | 36 | export async function unlock( 37 | repository: string, 38 | passphrase: string, 39 | ): Promise { 40 | const message: UNLOCK_EVENT = { 41 | type: 'UNLOCK', 42 | repository, 43 | passphrase, 44 | }; 45 | 46 | await browser.runtime.sendMessage(message); 47 | } 48 | 49 | export async function assignNewEmail(domain: string): Promise { 50 | const message: ASSIGN_NEW_EMAIL_EVENT = { 51 | type: 'ASSIGN_NEW_EMAIL', 52 | domain, 53 | }; 54 | const email = await browser.runtime.sendMessage(message); 55 | 56 | return email; 57 | } 58 | -------------------------------------------------------------------------------- /packages/extensions/src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { QueryClientProvider, QueryClient } from 'react-query'; 4 | import { persistQueryClient } from 'react-query/persistQueryClient-experimental'; 5 | import { createAsyncStoragePersistor } from 'react-query/createAsyncStoragePersistor-experimental'; 6 | import { browser } from 'webextension-polyfill-ts'; 7 | import Popup from './Popup'; 8 | import '../style.css'; 9 | 10 | const queryClient = new QueryClient({ 11 | defaultOptions: { 12 | queries: { 13 | retry: false, 14 | }, 15 | }, 16 | }); 17 | const persistor = createAsyncStoragePersistor({ 18 | storage: { 19 | getItem: async (key: string): Promise => { 20 | const item = await browser.storage.local.get(key); 21 | 22 | if (typeof item?.[key] === 'undefined') { 23 | return null; 24 | } 25 | 26 | return item?.[key]; 27 | }, 28 | setItem: async (key: string, value: string): Promise => { 29 | await browser.storage.local.set({ 30 | [key]: value, 31 | }); 32 | }, 33 | removeItem: async (key: string): Promise => { 34 | await browser.storage.local.remove(key); 35 | }, 36 | }, 37 | }); 38 | 39 | const popup = ( 40 | 41 | 42 | 43 | ); 44 | 45 | persistQueryClient({ queryClient, persistor }); 46 | ReactDOM.render(popup, document.getElementById('popup-root')); 47 | -------------------------------------------------------------------------------- /packages/extensions/src/style.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /packages/extensions/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | recipents: string[]; 3 | pendingEmail: string; 4 | } 5 | 6 | export interface Session { 7 | repository: string; 8 | options: string[]; 9 | configByDomain: Record | null; 10 | emails: string[]; 11 | } 12 | 13 | export interface GET_SESSION_EVENT { 14 | type: 'GET_SESSION'; 15 | } 16 | 17 | export interface LOGIN_EVENT { 18 | type: 'LOGIN'; 19 | } 20 | 21 | export interface LOGOUT_EVENT { 22 | type: 'LOGOUT'; 23 | } 24 | 25 | export interface UNLOCK_EVENT { 26 | type: 'UNLOCK'; 27 | repository: string; 28 | passphrase: string; 29 | } 30 | 31 | export interface ASSIGN_NEW_EMAIL_EVENT { 32 | type: 'ASSIGN_NEW_EMAIL'; 33 | domain: string; 34 | } 35 | 36 | export type Message = 37 | | GET_SESSION_EVENT 38 | | LOGIN_EVENT 39 | | LOGOUT_EVENT 40 | | UNLOCK_EVENT 41 | | ASSIGN_NEW_EMAIL_EVENT; 42 | -------------------------------------------------------------------------------- /packages/extensions/src/utils.ts: -------------------------------------------------------------------------------- 1 | export async function copyText(text: string): Promise { 2 | if (document.queryCommandSupported?.('copy')) { 3 | let hasError = false; 4 | const textarea = document.createElement('textarea'); 5 | textarea.textContent = text; 6 | textarea.style.position = 'fixed'; 7 | document.body.appendChild(textarea); 8 | textarea.select(); 9 | 10 | try { 11 | // Security exception may be thrown by some browsers. 12 | document.execCommand('copy'); 13 | } catch (error) { 14 | hasError = true; 15 | console.log( 16 | '[Error] Failed to copy text with the execCommand API', 17 | error, 18 | ); 19 | } finally { 20 | document.body.removeChild(textarea); 21 | } 22 | 23 | if (!hasError) { 24 | return; 25 | } 26 | } 27 | 28 | try { 29 | await navigator.clipboard.writeText(text); 30 | } catch (error) { 31 | console.log('[Error] Failed to copy text with the Clipboard API', error); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/extensions/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./public/*.html', './src/**/*.ts', './src/**/*.tsx'], 3 | darkMode: false, 4 | theme: { 5 | extend: { 6 | colors: { 7 | primary: '#537780', 8 | secondary: '#fffcca', 9 | }, 10 | }, 11 | }, 12 | variants: {}, 13 | plugins: [], 14 | }; 15 | -------------------------------------------------------------------------------- /packages/extensions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "jsx": "react", 7 | "declaration": false, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "strictPropertyInitialization": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/extensions/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const FilemanagerPlugin = require('filemanager-webpack-plugin'); 4 | const TerserPlugin = require('terser-webpack-plugin'); 5 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 6 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 7 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 8 | const ExtensionReloader = require('webpack-extension-reloader'); 9 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 10 | const WextManifestWebpackPlugin = require('wext-manifest-webpack-plugin'); 11 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 12 | 13 | const publicPath = path.join(__dirname, 'public'); 14 | const sourcePath = path.join(__dirname, 'src'); 15 | const destPath = path.join(__dirname, 'dist'); 16 | const nodeEnv = process.env.NODE_ENV || 'development'; 17 | const targetBrowser = process.env.TARGET_BROWSER; 18 | const outputPath = path.join(destPath, targetBrowser); 19 | 20 | const getExtensionFileType = (browser) => { 21 | if (browser === 'opera') { 22 | return 'crx'; 23 | } 24 | 25 | if (browser === 'firefox') { 26 | return 'xpi'; 27 | } 28 | 29 | return 'zip'; 30 | }; 31 | 32 | const plugins = [ 33 | // Plugin to not generate js bundle for manifest entry 34 | new WextManifestWebpackPlugin(), 35 | // Generate sourcemaps 36 | new webpack.SourceMapDevToolPlugin({ filename: false }), 37 | // environmental variables 38 | new webpack.EnvironmentPlugin(['NODE_ENV', 'TARGET_BROWSER']), 39 | // delete previous build files 40 | new CleanWebpackPlugin({ 41 | cleanOnceBeforeBuildPatterns: [ 42 | outputPath, 43 | `${outputPath}.${getExtensionFileType(targetBrowser)}`, 44 | ], 45 | cleanStaleWebpackAssets: false, 46 | verbose: true, 47 | }), 48 | new HtmlWebpackPlugin({ 49 | template: path.join(publicPath, 'popup.html'), 50 | inject: 'body', 51 | chunks: ['popup'], 52 | hash: true, 53 | filename: 'popup.html', 54 | }), 55 | // write css file(s) to build folder 56 | new MiniCssExtractPlugin({ filename: 'css/[name].css' }), 57 | // copy static assets 58 | new CopyWebpackPlugin({ 59 | patterns: [{ from: 'public/assets', to: 'assets' }], 60 | }), 61 | ]; 62 | 63 | if (nodeEnv === 'development') { 64 | plugins.push( 65 | // plugin to enable browser reloading in development mode 66 | new ExtensionReloader({ 67 | port: 9090, 68 | reloadPage: true, 69 | entries: { 70 | // TODO: reload manifest on update 71 | background: 'background', 72 | extensionPage: ['popup'], 73 | }, 74 | }), 75 | ); 76 | } 77 | 78 | module.exports = { 79 | devtool: false, // https://github.com/webpack/webpack/issues/1194#issuecomment-560382342 80 | 81 | stats: { 82 | all: false, 83 | builtAt: true, 84 | errors: true, 85 | hash: true, 86 | }, 87 | 88 | mode: nodeEnv, 89 | 90 | watch: nodeEnv === 'development', 91 | 92 | entry: { 93 | manifest: path.join(sourcePath, 'manifest.json'), 94 | background: path.join(sourcePath, 'background', 'index.ts'), 95 | popup: path.join(sourcePath, 'popup', 'index.tsx'), 96 | }, 97 | 98 | output: { 99 | path: outputPath, 100 | filename: 'js/[name].bundle.js', 101 | }, 102 | 103 | resolve: { 104 | extensions: ['.ts', '.tsx', '.js', '.json'], 105 | }, 106 | 107 | module: { 108 | rules: [ 109 | { 110 | type: 'javascript/auto', // prevent webpack handling json with its own loaders, 111 | test: /manifest\.json$/, 112 | use: { 113 | loader: 'wext-manifest-loader', 114 | options: { 115 | usePackageJSONVersion: true, // set to false to not use package.json version for manifest 116 | }, 117 | }, 118 | exclude: /node_modules/, 119 | }, 120 | { 121 | test: /\.(js|ts)x?$/, 122 | loader: 'babel-loader', 123 | exclude: /node_modules/, 124 | }, 125 | { 126 | test: /\.css$/, 127 | use: [ 128 | { 129 | loader: MiniCssExtractPlugin.loader, // It creates a CSS file per JS file which contains CSS 130 | }, 131 | { 132 | loader: 'css-loader', // Takes the CSS files and returns the CSS with imports and url(...) for Webpack 133 | options: { 134 | sourceMap: true, 135 | }, 136 | }, 137 | { 138 | loader: 'postcss-loader', 139 | options: { 140 | postcssOptions: { 141 | plugins: [ 142 | [ 143 | 'autoprefixer', 144 | { 145 | // Options 146 | }, 147 | ], 148 | ], 149 | }, 150 | }, 151 | }, 152 | 'resolve-url-loader', // Rewrites relative paths in url() statements 153 | ], 154 | }, 155 | ], 156 | }, 157 | 158 | plugins, 159 | 160 | optimization: { 161 | minimize: nodeEnv !== 'development', 162 | minimizer: [ 163 | new TerserPlugin({ 164 | parallel: true, 165 | terserOptions: { 166 | format: { 167 | comments: false, 168 | }, 169 | }, 170 | extractComments: false, 171 | }), 172 | new OptimizeCSSAssetsPlugin({ 173 | cssProcessorPluginOptions: { 174 | preset: ['default', { discardComments: { removeAll: true } }], 175 | }, 176 | }), 177 | new FilemanagerPlugin({ 178 | events: { 179 | onEnd: { 180 | archive: [ 181 | { 182 | format: 'zip', 183 | source: outputPath, 184 | destination: `${outputPath}.${getExtensionFileType( 185 | targetBrowser, 186 | )}`, 187 | options: { zlib: { level: 6 } }, 188 | }, 189 | ], 190 | }, 191 | }, 192 | }), 193 | ], 194 | }, 195 | }; 196 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules 3 | 4 | # Remix build 5 | /.cache 6 | /build 7 | /public/build 8 | 9 | # Postcss build 10 | /app/styles 11 | 12 | # Worker related 13 | /worker.js 14 | -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # @maildog/web 2 | 3 | Built with [Remix](https://remix.run/), deployed to [Cloudflare Workers](https://workers.cloudflare.com/). Bootstrapped based on https://github.com/GregBrimble/remix 4 | 5 | ## Development 6 | 7 | ```sh 8 | npm start 9 | ``` 10 | 11 | This starts your app in development mode, rebuilding assets on file changes. 12 | 13 | ## Deployment 14 | 15 | ```sh 16 | npm run publish 17 | ``` 18 | 19 | This will first build both the remix app for production and output a worker file `worker.js`. Assets from the `public` folder will be uploaded to Workers KV and the worker file will be deployed together. 20 | -------------------------------------------------------------------------------- /packages/web/app/api.ts: -------------------------------------------------------------------------------- 1 | import type { HeadersFunction, Response } from 'remix'; 2 | import { useLoaderData, useActionData, useMatches, json } from 'remix'; 3 | 4 | /** 5 | * Default HeadersFunction for API Route. 6 | * Prioritize loader headers and complemented by parent headers 7 | * @returns {HeadersFunction} headersFunction 8 | */ 9 | export const apiHeaders: HeadersFunction = ({ 10 | loaderHeaders, 11 | parentHeaders, 12 | }) => { 13 | return { 14 | ...Object.fromEntries(parentHeaders), 15 | ...Object.fromEntries(loaderHeaders), 16 | }; 17 | }; 18 | 19 | /** 20 | * Helpers for generating a 401 JSON response 21 | * @returns {Response} 401 Response 22 | */ 23 | export function badRequest(): Response { 24 | return json({ error: { message: 'Bad Request' } }, 400); 25 | } 26 | 27 | /** 28 | * Helpers for generating a 401 JSON response 29 | * @returns {Response} 401 Response 30 | */ 31 | export function unauthorized(): Response { 32 | return json({ error: { message: 'Unauthorized' } }, 401); 33 | } 34 | 35 | /** 36 | * Helpers for generating a 404 JSON response 37 | * @returns {Response} 404 Response 38 | */ 39 | export function notFound(): Response { 40 | return json({ error: { message: 'Not Found' } }, 404); 41 | } 42 | 43 | /** 44 | * Checks if the result should be rendered with the Document layout 45 | * Based on handle defined on route module 46 | * Default to true unless specifically defined and assigned with `false` 47 | * @returns {boolean} 48 | */ 49 | export function useShouldRenderDocument(): boolean { 50 | const matches = useMatches(); 51 | 52 | return !matches.find((match) => match?.handle?.document === false); 53 | } 54 | 55 | /** 56 | * Unescape JSON data santized by the react-dom renderer. 57 | * It does not cover all scenarios and probably buggy. 58 | * It is better to be replaced with a custom react renderer to avoid sanitization. 59 | * @param markup Santized JSON rendered with `ReactDOM.renderToString()` 60 | * @returns A JSON text 61 | */ 62 | export function unescapeHtml(markup: string): string { 63 | return markup 64 | .replace(/&/g, '&') 65 | .replace(/</g, '<') 66 | .replace(/>/g, '>') 67 | .replace(/"/g, '"') 68 | .replace(/&#x(\d+);/g, (_, hex) => String.fromCharCode(Number(`0x${hex}`))) 69 | .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec)); 70 | } 71 | 72 | /** 73 | * Format the provided data to JSON string. 74 | * Print with a more readable format on development environment 75 | * @param {any} data from the loader function 76 | * @returns JSON string 77 | */ 78 | export function jsonStringify(data: any): string { 79 | if (process.env.NODE_ENV === 'development') { 80 | return JSON.stringify(data, null, 2); 81 | } 82 | 83 | return JSON.stringify(data); 84 | } 85 | 86 | /** 87 | * A Remix Route component which simply return the loaders data in JSON format 88 | * @returns {string} JSON string 89 | */ 90 | export function JsonRoute(): string { 91 | const loaderData = useLoaderData(); 92 | const actionData = useActionData(); 93 | 94 | return jsonStringify(actionData ?? loaderData); 95 | } 96 | -------------------------------------------------------------------------------- /packages/web/app/auth.ts: -------------------------------------------------------------------------------- 1 | import type { Session, Request, Response } from 'remix'; 2 | import { createCookieSessionStorage, redirect } from 'remix'; 3 | import { createToken, getWebFlowAuthorizationUrl } from './github'; 4 | 5 | /** 6 | * Store session inside cookie with the strictest setup 7 | * With an exception on `sameSite` being `lax` due to installation flow triggered by Github 8 | */ 9 | const sessionStorage = createCookieSessionStorage({ 10 | cookie: { 11 | name: '_session', 12 | httpOnly: true, 13 | path: '/', 14 | sameSite: 'lax', 15 | secrets: process.env.SESSION_SECRETS?.split(',') ?? [], 16 | secure: process.env.NODE_ENV !== 'development', 17 | }, 18 | }); 19 | 20 | /** 21 | * Create session object based on the header cookie 22 | * @param {Request} request 23 | * @returns {Session} current session 24 | */ 25 | export async function getSession(request: Request): Promise { 26 | const cookie = request.headers.get('Cookie'); 27 | const session = await sessionStorage.getSession(cookie); 28 | 29 | return session; 30 | } 31 | 32 | /** 33 | * Initiate Github OAuth Web Application flow 34 | * @link https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#web-application-flow 35 | * @param request 36 | * @returns response based on current session 37 | */ 38 | export async function login(request: Request): Promise { 39 | const session = await getSession(request); 40 | 41 | if (session.has('accessToken')) { 42 | return redirect('/'); 43 | } 44 | 45 | const authorizationUrl = getWebFlowAuthorizationUrl( 46 | `${process.env.WEB_URL ?? 'http://localhost:3000'}/callback`, 47 | Math.random().toString(36).slice(2), 48 | ); 49 | 50 | return redirect(authorizationUrl); 51 | } 52 | 53 | /** 54 | * Delete session cookie 55 | * @param request 56 | * @returns response based on current session 57 | */ 58 | export async function logout(request: Request): Promise { 59 | const session = await getSession(request); 60 | 61 | return redirect('/', { 62 | headers: { 63 | 'Set-Cookie': await sessionStorage.destroySession(session), 64 | }, 65 | }); 66 | } 67 | 68 | /** 69 | * Exchange provided code with an accessToken and save it to session 70 | * The redirection could be triggered with 2 different flows: 71 | * (1) After authorisation (login), or 72 | * (2) After installation 73 | * @link https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github 74 | * @param request 75 | * @returns response based on current session 76 | */ 77 | export async function callback(request: Request): Promise { 78 | const url = new URL(request.url); 79 | const code = url.searchParams.get('code'); 80 | const state = url.searchParams.get('state'); 81 | // FIXME: Delete it or an extra check to verify the installation_id provided 82 | // const installationId = url.searchParams.get('installation_id'); 83 | 84 | if (!code) { 85 | return redirect('/'); 86 | } 87 | 88 | const session = await getSession(request); 89 | const token = await createToken(code, state !== null ? state : undefined); 90 | 91 | session.set('accessToken', token); 92 | console.log('[Auth] Success: ', JSON.stringify({ code, state }, null, 2)); 93 | 94 | return redirect('/', { 95 | headers: { 96 | 'Set-Cookie': await sessionStorage.commitSession(session), 97 | }, 98 | }); 99 | } 100 | -------------------------------------------------------------------------------- /packages/web/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { RemixBrowser } from 'remix'; 3 | 4 | ReactDOM.hydrate(, document); 5 | -------------------------------------------------------------------------------- /packages/web/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOMServer from 'react-dom/server'; 2 | import type { EntryContext } from 'remix'; 3 | import { RemixServer } from 'remix'; 4 | import { unescapeHtml } from './api'; 5 | 6 | export default function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext, 11 | ) { 12 | const markup = ReactDOMServer.renderToString( 13 | , 14 | ); 15 | 16 | if (!responseHeaders.has('Content-Type')) { 17 | responseHeaders.set('Content-Type', 'text/html'); 18 | } 19 | 20 | const body = 21 | responseHeaders.get('Content-Type') === 'text/html' 22 | ? `${markup}` 23 | : unescapeHtml(markup); 24 | 25 | return new Response(body, { 26 | status: responseStatusCode, 27 | headers: responseHeaders, 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /packages/web/app/github.tsx: -------------------------------------------------------------------------------- 1 | import { App } from '@octokit/app'; 2 | 3 | type Octokit = ReturnType; 4 | 5 | const app = new App({ 6 | appId: process.env.GITHUB_APP_ID ?? '', 7 | privateKey: process.env.GITHUB_APP_PRIVATE_KEY ?? '', 8 | oauth: { 9 | clientId: process.env.GITHUB_APP_CLIENT_ID ?? '', 10 | clientSecret: process.env.GITHUB_APP_CLIENT_SECRET ?? '', 11 | }, 12 | }); 13 | 14 | /** 15 | * Generate url for initiating the web application flow 16 | * @param {string} redirectUrl The URL in your application where users will be sent after authorization. Must be configured on Github App 17 | * @param {string} state An unguessable random string 18 | * @returns authorization url 19 | */ 20 | export function getWebFlowAuthorizationUrl( 21 | redirectUrl: string, 22 | state: string, 23 | ): string { 24 | const result = app.oauth.getWebFlowAuthorizationUrl({ 25 | redirectUrl, 26 | state, 27 | }); 28 | 29 | return result.url; 30 | } 31 | 32 | /** 33 | * Excahnge token based on the provided code 34 | * @param {string} code The code received after redirection 35 | * @param {string}[state] The unguessable random string provided with web application flow 36 | * @returns accessToken 37 | */ 38 | export async function createToken( 39 | code: string, 40 | state?: string, 41 | ): Promise { 42 | const response = await app.oauth.createToken({ code, state }); 43 | 44 | return response.authentication.token; 45 | } 46 | 47 | /** 48 | * Get Octokit client based on user access token 49 | * @param {string} token user access token 50 | * @returns {Promise} The octokit client 51 | */ 52 | export async function getUserOctokit(token: string): Promise { 53 | const octokit = await app.oauth.getUserOctokit({ token }); 54 | 55 | return octokit; 56 | } 57 | 58 | /** 59 | * List all instatlled repositories with name being `maildog` 60 | * @param token {string} user access token 61 | * @returns {string[]} repository name list in `owner/repo` format 62 | */ 63 | export async function listInstalledRepositories( 64 | token: string, 65 | ): Promise { 66 | const octokit = await getUserOctokit(token); 67 | const { data } = await octokit.request('GET /user/installations'); 68 | const repos = []; 69 | 70 | if (data.total_count > 0) { 71 | for (const installation of data.installations) { 72 | for await (const { repository } of app.eachRepository.iterator({ 73 | installationId: installation.id, 74 | })) { 75 | if (repository.name !== 'maildog') { 76 | // FIXME: Check for package.json instead of repository name 77 | continue; 78 | } 79 | 80 | repos.push(repository.full_name); 81 | } 82 | } 83 | } 84 | 85 | return repos; 86 | } 87 | 88 | /** 89 | * Lookup the encrypted config file content from the specific repository 90 | * @param token {string} user access token 91 | * @param owner {string} 92 | * @param repo {string} 93 | * @param ref {string} The name of the commit/branch/tag 94 | * @returns {Promise<{ encoding: string; content: string; sha: string; }>} config file details 95 | */ 96 | export async function getEncrypedConfig( 97 | token: string, 98 | owner: string, 99 | repo: string, 100 | ref?: string, 101 | ): Promise<{ encoding: string; content: string; sha: string } | null> { 102 | const octokit = await getUserOctokit(token); 103 | 104 | try { 105 | const config = await octokit.request( 106 | 'GET /repos/{owner}/{repo}/contents/{path}', 107 | { 108 | owner, 109 | repo, 110 | ref, 111 | path: 'maildog.config.json.asc', 112 | }, 113 | ); 114 | 115 | return { 116 | encoding: config.data.encoding, 117 | content: config.data.content, 118 | sha: config.data.sha, 119 | }; 120 | } catch (e) { 121 | console.log( 122 | `[Error] Fail to lookup config from ${owner}/${repo} with ref ${ 123 | ref ?? 'default' 124 | }`, 125 | ); 126 | return null; 127 | } 128 | } 129 | 130 | /** 131 | * Save the encrypted config file content in the specific repository 132 | * @param token {string} user access token 133 | * @param owner {string} 134 | * @param repo {string} 135 | * @param content {string} content of the encrypted config in base 64 encoding 136 | * @param branch {string} The name of the branch committed on 137 | * @param sha {shring} The blob SHA of the file being replaced 138 | * @returns {Promise} Created file sha 139 | */ 140 | export async function saveEncryptedConfig( 141 | token: string, 142 | owner: string, 143 | repo: string, 144 | content: string, 145 | branch?: string, 146 | sha?: string, 147 | ): Promise { 148 | try { 149 | const octokit = await getUserOctokit(token); 150 | const response = await octokit.request( 151 | 'PUT /repos/{owner}/{repo}/contents/{path}', 152 | { 153 | owner, 154 | repo, 155 | path: 'maildog.config.json.asc', 156 | message: 'build(extensions): update config', 157 | content, 158 | sha, 159 | branch, 160 | }, 161 | ); 162 | 163 | return response.data?.content?.sha ?? null; 164 | } catch (e) { 165 | console.log( 166 | `[Error] Fail to save config on ${owner}/${repo} at the branch ${ 167 | branch ?? 'default' 168 | }`, 169 | ); 170 | 171 | throw e; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /packages/web/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { LinksFunction, MetaFunction } from 'remix'; 2 | import { Meta, Links, Scripts, LiveReload } from 'remix'; 3 | import { Outlet } from 'react-router-dom'; 4 | import { useShouldRenderDocument } from './api'; 5 | import stylesUrl from './styles/global.css'; 6 | 7 | export let meta: MetaFunction = () => { 8 | return { 9 | title: 'maildog', 10 | description: 11 | 'Hosting your own email forwarding service on AWS and manage it with Github Actions', 12 | author: 'Edmund Hung', 13 | keywords: 'aws,email,github', 14 | }; 15 | }; 16 | 17 | export let links: LinksFunction = () => { 18 | return [{ rel: 'stylesheet', href: stylesUrl }]; 19 | }; 20 | 21 | function Document({ children }: { children: React.ReactNode }) { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | {process.env.NODE_ENV === 'development' && } 35 | 36 | 37 | ); 38 | } 39 | 40 | export default function App() { 41 | const shouldRenderDocument = useShouldRenderDocument(); 42 | 43 | if (!shouldRenderDocument) { 44 | return ; 45 | } 46 | 47 | return ( 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | export function ErrorBoundary({ error }: { error: Error }) { 55 | return ( 56 | 57 |

App Error

58 |
{error.message}
59 |

60 | Replace this UI with what you want users to see when your app throws 61 | uncaught errors. 62 |

63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /packages/web/app/routes/404.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | export default function FourOhFour() { 4 | return ( 5 |
6 |

404

7 |
Bark! Bark! 🐶
8 |
9 | Follow the maildog{' '} 10 | 14 | home 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/web/app/routes/api.tsx: -------------------------------------------------------------------------------- 1 | import type { HeadersFunction } from 'remix'; 2 | import { Outlet } from 'react-router-dom'; 3 | import { jsonStringify } from '../api'; 4 | 5 | export const headers: HeadersFunction = () => { 6 | return { 7 | 'Content-Type': 'application/json', 8 | }; 9 | }; 10 | 11 | export let handle = { 12 | document: false, 13 | }; 14 | 15 | export function ErrorBoundary({ error }: { error: Error }): string { 16 | return jsonStringify({ 17 | error: { message: error.message }, 18 | }); 19 | } 20 | 21 | export default function ApiRoute() { 22 | return ; 23 | } 24 | -------------------------------------------------------------------------------- /packages/web/app/routes/api/$owner.$repo.config.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction, ActionFunction } from 'remix'; 2 | import { json } from 'remix'; 3 | import { 4 | JsonRoute, 5 | apiHeaders, 6 | unauthorized, 7 | notFound, 8 | badRequest, 9 | } from '../../api'; 10 | import { getEncrypedConfig, saveEncryptedConfig } from '../../github'; 11 | import { getSession } from '../../auth'; 12 | 13 | export const headers = apiHeaders; 14 | 15 | export let action: ActionFunction = async ({ request, params }) => { 16 | if (request.method.toUpperCase() !== 'PUT') { 17 | return notFound(); 18 | } 19 | 20 | const session = await getSession(request); 21 | 22 | if (!session.has('accessToken')) { 23 | return unauthorized(); 24 | } 25 | 26 | const { owner, repo } = params; 27 | 28 | if (!owner || !repo) { 29 | return badRequest(); 30 | } 31 | 32 | const token = session.get('accessToken'); 33 | const body = await request.clone().json(); 34 | const result = await saveEncryptedConfig( 35 | token, 36 | owner, 37 | repo, 38 | body.content, 39 | 'production', 40 | body.sha, 41 | ); 42 | 43 | return json({ 44 | data: result, 45 | }); 46 | }; 47 | 48 | export let loader: LoaderFunction = async ({ request, params }) => { 49 | const session = await getSession(request); 50 | 51 | if (!session.has('accessToken')) { 52 | return unauthorized(); 53 | } 54 | 55 | const { owner, repo } = params; 56 | 57 | if (!owner || !repo) { 58 | return badRequest(); 59 | } 60 | 61 | const token = session.get('accessToken'); 62 | const config = await getEncrypedConfig(token, owner, repo, 'production'); 63 | 64 | if (!config) { 65 | return notFound(); 66 | } 67 | 68 | return json({ 69 | data: config, 70 | }); 71 | }; 72 | 73 | export default JsonRoute; 74 | -------------------------------------------------------------------------------- /packages/web/app/routes/api/session.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { json } from 'remix'; 3 | import { JsonRoute, apiHeaders, unauthorized } from '../../api'; 4 | import { listInstalledRepositories } from '../../github'; 5 | import { getSession } from '../../auth'; 6 | 7 | export const headers = apiHeaders; 8 | 9 | export let loader: LoaderFunction = async ({ request }) => { 10 | const session = await getSession(request); 11 | 12 | if (!session.has('accessToken')) { 13 | return unauthorized(); 14 | } 15 | 16 | const token = session.get('accessToken'); 17 | const repos = await listInstalledRepositories(token); 18 | 19 | return json({ 20 | data: { repos }, 21 | }); 22 | }; 23 | 24 | export default JsonRoute; 25 | -------------------------------------------------------------------------------- /packages/web/app/routes/callback.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { callback } from '../auth'; 3 | 4 | export let loader: LoaderFunction = async ({ request }) => { 5 | return callback(request); 6 | }; 7 | 8 | export default function Callback(): null { 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { useRouteData, redirect, json } from 'remix'; 3 | import { listInstalledRepositories } from '../github'; 4 | import { getSession } from '../auth'; 5 | 6 | export let loader: LoaderFunction = async ({ request }) => { 7 | const session = await getSession(request); 8 | 9 | if (!session.has('accessToken')) { 10 | return json({ 11 | isAuthenticated: false, 12 | repos: [], 13 | }); 14 | } 15 | 16 | const token = session.get('accessToken'); 17 | const repos = await listInstalledRepositories(token); 18 | 19 | if (repos.length === 0) { 20 | console.log( 21 | 'No installed repository found; Redirecting user to Github App installation page', 22 | ); 23 | return redirect( 24 | 'https://github.com/apps/maildog-trainer/installations/new', 25 | ); 26 | } 27 | 28 | return json({ 29 | isAuthenticated: true, 30 | repos, 31 | }); 32 | }; 33 | 34 | export default function Index(): React.ReactElement { 35 | const { isAuthenticated, repos } = useRouteData(); 36 | 37 | return ( 38 |
39 |

Bark! 🐶

40 |
41 | Your{' '} 42 | 46 | {repos[0] ?? 'maildog'} 47 | {' '} 48 | is waiting for you 49 |
50 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /packages/web/app/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { login } from '../auth'; 3 | 4 | export let loader: LoaderFunction = async ({ request }) => { 5 | return login(request); 6 | }; 7 | 8 | export default function Login(): null { 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/app/routes/logout.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from 'remix'; 2 | import { logout } from '../auth'; 3 | 4 | export let loader: LoaderFunction = async ({ request }) => { 5 | return logout(request); 6 | }; 7 | 8 | export default function Logout(): null { 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "@maildog/web", 4 | "main": "./worker.js", 5 | "description": "Train your maildog with ease", 6 | "license": "MIT", 7 | "scripts": { 8 | "build:css": "postcss styles --base styles --dir app/styles --env production", 9 | "build:remix": "remix build", 10 | "build:worker": "node ./worker/build.js", 11 | "build": "npm run build:css && npm run build:remix && npm run build:worker", 12 | "watch:js": "remix run", 13 | "watch:css": "postcss styles --base styles --dir app/styles -w", 14 | "type-checking": "tsc --noEmit", 15 | "prestart": "postcss styles --base styles --dir app/styles", 16 | "start": "concurrently \"npm:watch:*\"", 17 | "preview": "wrangler publish", 18 | "publish": "wrangler publish -e production" 19 | }, 20 | "dependencies": { 21 | "@cloudflare/kv-asset-handler": "^0.1.1", 22 | "@octokit/app": "^12.0.3", 23 | "@remix-run/node": "^0.18.0-pre.0", 24 | "@remix-run/react": "^0.18.0-pre.0", 25 | "@remix-run/serve": "^0.18.0-pre.0", 26 | "react": "^17.0.2", 27 | "react-dom": "^17.0.2", 28 | "react-router-dom": "^6.0.0-beta.0", 29 | "remix": "^0.18.0-pre.0" 30 | }, 31 | "devDependencies": { 32 | "@cloudflare/workers-types": "^2.2.2", 33 | "@cloudflare/wrangler": "^1.16.1", 34 | "@remix-run/dev": "^0.18.0-pre.0", 35 | "@types/react": "^17.0.15", 36 | "@types/react-dom": "^17.0.9", 37 | "autoprefixer": "^10.2.5", 38 | "concurrently": "^6.1.0", 39 | "esbuild": "^0.12.1", 40 | "esbuild-plugin-alias": "^0.1.2", 41 | "postcss": "^8.3.0", 42 | "postcss-cli": "^8.3.1", 43 | "tailwindcss": "^2.1.2", 44 | "typescript": "^4.1.2" 45 | }, 46 | "engines": { 47 | "node": ">=14" 48 | }, 49 | "sideEffects": false 50 | } 51 | -------------------------------------------------------------------------------- /packages/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web/remix.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | appDirectory: 'app', 3 | browserBuildDirectory: 'public/build', 4 | publicPath: '/build/', 5 | serverBuildDirectory: 'build', 6 | devServerPort: 8002, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/web/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /packages/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./app/**/*.tsx', './app/**/*.jsx', './app/**/*.js', './app/**/*.ts'], 3 | darkMode: 'media', 4 | theme: {}, 5 | variants: {}, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "target": "esnext", 5 | "module": "commonjs", 6 | "jsx": "react-jsx", 7 | "lib": ["DOM", "DOM.Iterable", "esnext", "webworker"], 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "noImplicitThis": true, 13 | "alwaysStrict": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "strictPropertyInitialization": true, 21 | "types": ["@cloudflare/workers-types"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/web/worker/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const alias = require('esbuild-plugin-alias'); 3 | 4 | esbuild.build({ 5 | entryPoints: ['worker/index.ts'], 6 | bundle: true, 7 | format: 'esm', 8 | define: { 'process.env.NODE_ENV': '"production"' }, 9 | outfile: './worker.js', 10 | plugins: [ 11 | alias({ 12 | 'fs': require.resolve('./null.js'), 13 | 'crypto': require.resolve('./null.js'), 14 | 'path': require.resolve('./null.js'), 15 | 'node-fetch': require.resolve('./global-fetch.js'), 16 | }), 17 | ], 18 | }); 19 | -------------------------------------------------------------------------------- /packages/web/worker/global-fetch.js: -------------------------------------------------------------------------------- 1 | export const Headers = global.Headers; 2 | export const Request = global.Request; 3 | export const Response = global.Response; 4 | 5 | export default global.fetch; 6 | -------------------------------------------------------------------------------- /packages/web/worker/index.ts: -------------------------------------------------------------------------------- 1 | import { createEventListener } from './worker'; 2 | // @ts-expect-error 3 | import build from '../build/index.js'; 4 | 5 | const eventListener = createEventListener({ 6 | build, 7 | getLoadContext() { 8 | return {}; 9 | }, 10 | }); 11 | 12 | addEventListener('fetch', eventListener); 13 | -------------------------------------------------------------------------------- /packages/web/worker/null.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /packages/web/worker/worker.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAssetFromKV, 3 | MethodNotAllowedError, 4 | NotFoundError, 5 | } from '@cloudflare/kv-asset-handler'; 6 | import { 7 | createRequestHandler as createNodeRequestHandler, 8 | ServerBuild, 9 | Request as NodeRequest, 10 | } from '@remix-run/node'; 11 | 12 | async function handleAsset( 13 | event: FetchEvent, 14 | mode?: string, 15 | ): Promise { 16 | try { 17 | const options: Parameters[1] = {}; 18 | 19 | if (mode === 'development') { 20 | options.cacheControl = { 21 | bypassCache: true, 22 | }; 23 | } 24 | 25 | const asset = await getAssetFromKV(event, options); 26 | const response = new Response(asset.body, asset); 27 | 28 | response.headers.set('X-XSS-Protection', '1; mode=block'); 29 | response.headers.set('X-Content-Type-Options', 'nosniff'); 30 | response.headers.set('X-Frame-Options', 'DENY'); 31 | response.headers.set('Referrer-Policy', 'unsafe-url'); 32 | response.headers.set('Feature-Policy', 'none'); 33 | 34 | return response; 35 | } catch (error) { 36 | if ( 37 | !( 38 | error instanceof MethodNotAllowedError || error instanceof NotFoundError 39 | ) 40 | ) { 41 | console.log('handleAssetRequest throw error', error.message); 42 | throw error; 43 | } 44 | 45 | return null; 46 | } 47 | } 48 | 49 | function createRequestHandler( 50 | build: ServerBuild, 51 | mode?: string, 52 | ): (request: Request, loadContext?: any) => Promise { 53 | const handleNodeRequest = createNodeRequestHandler(build, mode); 54 | 55 | return async (request: Request, loadContext?: any) => { 56 | const nodeRequest: NodeRequest = request as any; 57 | const nodeResponse = await handleNodeRequest(nodeRequest, loadContext); 58 | 59 | return nodeResponse as any; 60 | }; 61 | } 62 | 63 | function createEventHandler( 64 | build: ServerBuild, 65 | mode?: string, 66 | ): (event: FetchEvent, loadContext?: any) => Promise { 67 | const handleRequest = createRequestHandler(build, mode); 68 | 69 | return async (event: FetchEvent, loadContext?: any): Promise => { 70 | let response = await handleAsset(event, mode); 71 | 72 | if (!response) { 73 | response = await handleRequest(event.request, loadContext); 74 | } 75 | 76 | return response; 77 | }; 78 | } 79 | 80 | export function createEventListener({ 81 | build, 82 | getLoadContext, 83 | mode = process.env.NODE_ENV, 84 | }: { 85 | build: ServerBuild; 86 | getLoadContext?: (event: FetchEvent) => {}; 87 | mode?: string; 88 | }): (event: FetchEvent) => void { 89 | const handleEvent = createEventHandler(build, mode); 90 | 91 | return (event: FetchEvent) => { 92 | try { 93 | event.respondWith( 94 | handleEvent( 95 | event, 96 | typeof getLoadContext === 'function' 97 | ? getLoadContext(event) 98 | : undefined, 99 | ), 100 | ); 101 | } catch (e) { 102 | if (mode === 'development') { 103 | event.respondWith( 104 | new Response(e.message || e.toString(), { 105 | status: 500, 106 | }), 107 | ); 108 | return; 109 | } 110 | 111 | event.respondWith(new Response('Internal Error', { status: 500 })); 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /packages/web/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "maildog-web" 2 | type = "javascript" 3 | account_id = "c63d756a160ad09cd9a82553c77e9174" 4 | workers_dev = true 5 | kv_namespaces = [] 6 | 7 | [env.production] 8 | workers_dev = false 9 | zone_id = "06b50a41887577c55b758cd7df9ed9c4" 10 | route = "maildog.dev/*" 11 | 12 | [site] 13 | bucket = "./public" 14 | entry-point = "." 15 | 16 | [build] 17 | command = "npm run build" 18 | watch_dir = "app" 19 | 20 | [build.upload] 21 | format="service-worker" 22 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | // Setup Semantic Release plugins. 2 | // See https://semantic-release.gitbook.io/semantic-release/extending/plugins-list for more plugins. 3 | module.exports = { 4 | branches: ['main'], 5 | plugins: [ 6 | [ 7 | '@semantic-release/commit-analyzer', 8 | { 9 | preset: 'angular', // default 10 | releaseRules: [ 11 | // Explained on https://github.com/semantic-release/commit-analyzer#releaserules 12 | // Mark all chagnes with scope `web` & `extensions` as a patch until official release 13 | { scope: 'web', release: false }, 14 | { scope: 'extensions', release: false }, 15 | { type: 'feat', scope: 'web', release: 'patch' }, 16 | { type: 'fix', scope: 'web', release: 'patch' }, 17 | { type: 'perf', scope: 'web', release: 'patch' }, 18 | { type: 'feat', scope: 'extensions', release: 'patch' }, 19 | { type: 'fix', scope: 'extensions', release: 'patch' }, 20 | { type: 'perf', scope: 'extensions', release: 'patch' }, 21 | ], 22 | }, 23 | ], 24 | '@semantic-release/release-notes-generator', 25 | '@semantic-release/changelog', 26 | [ 27 | '@semantic-release/npm', 28 | { 29 | npmPublish: false, 30 | }, 31 | ], 32 | [ 33 | '@semantic-release/github', 34 | { 35 | failComment: false, 36 | }, 37 | ], 38 | '@semantic-release/git', 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /scripts/logCdkDiffResult.ts: -------------------------------------------------------------------------------- 1 | import { setFailed } from '@actions/core'; 2 | import { context, getOctokit } from '@actions/github'; 3 | import { command } from 'execa'; 4 | 5 | const title = '#### Check CDK Diff'; 6 | 7 | function generateComment( 8 | title: string, 9 | description: string, 10 | output: string, 11 | ): string { 12 | const body = [ 13 | title, 14 | description, 15 | '', 16 | '
', 17 | 'Show Output', 18 | '', 19 | '```', 20 | output, 21 | '```', 22 | '', 23 | '
', 24 | ].join('\n'); 25 | 26 | return body; 27 | } 28 | 29 | async function main(): Promise { 30 | const github = getOctokit(process.env.GITHUB_TOKEN ?? ''); 31 | const { exitCode, all } = await command('npx cdk diff', { all: true }); 32 | 33 | const comments = await github.rest.issues.listComments({ 34 | issue_number: context.issue.number, 35 | owner: context.repo.owner, 36 | repo: context.repo.repo, 37 | }); 38 | 39 | const previousComment = comments.data.find( 40 | (comment) => comment.body?.startsWith(title) ?? false, 41 | ); 42 | const body = generateComment( 43 | title, 44 | `Status: ${exitCode === 0 ? 'Success' : 'Failed'}, Exit Code: ${exitCode}`, 45 | all ?? '', 46 | ); 47 | 48 | if (previousComment) { 49 | await github.rest.issues.updateComment({ 50 | owner: context.repo.owner, 51 | repo: context.repo.repo, 52 | comment_id: previousComment.id, 53 | body, 54 | }); 55 | } else { 56 | await github.rest.issues.createComment({ 57 | issue_number: context.issue.number, 58 | owner: context.repo.owner, 59 | repo: context.repo.repo, 60 | body, 61 | }); 62 | } 63 | } 64 | 65 | main().catch((err: any): void => { 66 | console.error(err); 67 | setFailed(`Unhandled error: ${err}`); 68 | }); 69 | -------------------------------------------------------------------------------- /test/__snapshots__/maildog-stack.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MailDogStack should match the snapshot 1`] = ` 4 | Object { 5 | "Parameters": Object { 6 | "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaArtifactHashBA2599C9": Object { 7 | "Description": "Artifact hash for asset \\"4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfca\\"", 8 | "Type": "String", 9 | }, 10 | "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaS3Bucket5E038B25": Object { 11 | "Description": "S3 bucket for asset \\"4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfca\\"", 12 | "Type": "String", 13 | }, 14 | "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaS3VersionKey3D6A95C1": Object { 15 | "Description": "S3 key for asset version \\"4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfca\\"", 16 | "Type": "String", 17 | }, 18 | "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59ArtifactHashCFA7ACFB": Object { 19 | "Description": "Artifact hash for asset \\"ca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59\\"", 20 | "Type": "String", 21 | }, 22 | "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59S3Bucket12822632": Object { 23 | "Description": "S3 bucket for asset \\"ca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59\\"", 24 | "Type": "String", 25 | }, 26 | "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59S3VersionKey01F69698": Object { 27 | "Description": "S3 key for asset version \\"ca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59\\"", 28 | "Type": "String", 29 | }, 30 | "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406ArtifactHash4823FBF7": Object { 31 | "Description": "Artifact hash for asset \\"f43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406\\"", 32 | "Type": "String", 33 | }, 34 | "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406S3Bucket3EF97734": Object { 35 | "Description": "S3 bucket for asset \\"f43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406\\"", 36 | "Type": "String", 37 | }, 38 | "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406S3VersionKeyADCC52F6": Object { 39 | "Description": "S3 key for asset version \\"f43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406\\"", 40 | "Type": "String", 41 | }, 42 | }, 43 | "Resources": Object { 44 | "Bucket83908E77": Object { 45 | "DeletionPolicy": "Retain", 46 | "Properties": Object { 47 | "LifecycleConfiguration": Object { 48 | "Rules": Array [ 49 | Object { 50 | "ExpirationInDays": 365, 51 | "Status": "Enabled", 52 | "Transitions": Array [ 53 | Object { 54 | "StorageClass": "STANDARD_IA", 55 | "TransitionInDays": 30, 56 | }, 57 | Object { 58 | "StorageClass": "GLACIER", 59 | "TransitionInDays": 90, 60 | }, 61 | ], 62 | }, 63 | ], 64 | }, 65 | }, 66 | "Type": "AWS::S3::Bucket", 67 | "UpdateReplacePolicy": "Retain", 68 | }, 69 | "BucketPolicyE9A3008A": Object { 70 | "Properties": Object { 71 | "Bucket": Object { 72 | "Ref": "Bucket83908E77", 73 | }, 74 | "PolicyDocument": Object { 75 | "Statement": Array [ 76 | Object { 77 | "Action": "s3:PutObject", 78 | "Condition": Object { 79 | "StringEquals": Object { 80 | "aws:Referer": Object { 81 | "Ref": "AWS::AccountId", 82 | }, 83 | }, 84 | }, 85 | "Effect": "Allow", 86 | "Principal": Object { 87 | "Service": "ses.amazonaws.com", 88 | }, 89 | "Resource": Object { 90 | "Fn::Join": Array [ 91 | "", 92 | Array [ 93 | Object { 94 | "Fn::GetAtt": Array [ 95 | "Bucket83908E77", 96 | "Arn", 97 | ], 98 | }, 99 | "/example.com/*", 100 | ], 101 | ], 102 | }, 103 | }, 104 | Object { 105 | "Action": "s3:PutObject", 106 | "Condition": Object { 107 | "StringEquals": Object { 108 | "aws:Referer": Object { 109 | "Ref": "AWS::AccountId", 110 | }, 111 | }, 112 | }, 113 | "Effect": "Allow", 114 | "Principal": Object { 115 | "Service": "ses.amazonaws.com", 116 | }, 117 | "Resource": Object { 118 | "Fn::Join": Array [ 119 | "", 120 | Array [ 121 | Object { 122 | "Fn::GetAtt": Array [ 123 | "Bucket83908E77", 124 | "Arn", 125 | ], 126 | }, 127 | "/maildog.xyz/*", 128 | ], 129 | ], 130 | }, 131 | }, 132 | ], 133 | "Version": "2012-10-17", 134 | }, 135 | }, 136 | "Type": "AWS::S3::BucketPolicy", 137 | }, 138 | "DeadLetterQueue9F481546": Object { 139 | "DeletionPolicy": "Delete", 140 | "Properties": Object { 141 | "MessageRetentionPeriod": 1209600, 142 | }, 143 | "Type": "AWS::SQS::Queue", 144 | "UpdateReplacePolicy": "Delete", 145 | }, 146 | "DeadLetterQueuePolicyB1FB890C": Object { 147 | "Properties": Object { 148 | "PolicyDocument": Object { 149 | "Statement": Array [ 150 | Object { 151 | "Action": "sqs:SendMessage", 152 | "Condition": Object { 153 | "ArnEquals": Object { 154 | "aws:SourceArn": Object { 155 | "Ref": "MailFeedF42B1B20", 156 | }, 157 | }, 158 | }, 159 | "Effect": "Allow", 160 | "Principal": Object { 161 | "Service": "sns.amazonaws.com", 162 | }, 163 | "Resource": Object { 164 | "Fn::GetAtt": Array [ 165 | "DeadLetterQueue9F481546", 166 | "Arn", 167 | ], 168 | }, 169 | }, 170 | ], 171 | "Version": "2012-10-17", 172 | }, 173 | "Queues": Array [ 174 | Object { 175 | "Ref": "DeadLetterQueue9F481546", 176 | }, 177 | ], 178 | }, 179 | "Type": "AWS::SQS::QueuePolicy", 180 | }, 181 | "DispatcherAllowInvokeExampleStackMailFeedEF88B62C5E5A79A7": Object { 182 | "Properties": Object { 183 | "Action": "lambda:InvokeFunction", 184 | "FunctionName": Object { 185 | "Fn::GetAtt": Array [ 186 | "DispatcherD4A12972", 187 | "Arn", 188 | ], 189 | }, 190 | "Principal": "sns.amazonaws.com", 191 | "SourceArn": Object { 192 | "Ref": "MailFeedF42B1B20", 193 | }, 194 | }, 195 | "Type": "AWS::Lambda::Permission", 196 | }, 197 | "DispatcherD4A12972": Object { 198 | "DependsOn": Array [ 199 | "DispatcherServiceRoleDefaultPolicyDA413007", 200 | "DispatcherServiceRole904BCD09", 201 | ], 202 | "Properties": Object { 203 | "Code": Object { 204 | "S3Bucket": Object { 205 | "Ref": "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaS3Bucket5E038B25", 206 | }, 207 | "S3Key": Object { 208 | "Fn::Join": Array [ 209 | "", 210 | Array [ 211 | Object { 212 | "Fn::Select": Array [ 213 | 0, 214 | Object { 215 | "Fn::Split": Array [ 216 | "||", 217 | Object { 218 | "Ref": "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaS3VersionKey3D6A95C1", 219 | }, 220 | ], 221 | }, 222 | ], 223 | }, 224 | Object { 225 | "Fn::Select": Array [ 226 | 1, 227 | Object { 228 | "Fn::Split": Array [ 229 | "||", 230 | Object { 231 | "Ref": "AssetParameters4a5a920d14fd1108f20e63ef3dfb9f7ac54f8138149e89cc02f3154b9788dfcaS3VersionKey3D6A95C1", 232 | }, 233 | ], 234 | }, 235 | ], 236 | }, 237 | ], 238 | ], 239 | }, 240 | }, 241 | "DeadLetterConfig": Object { 242 | "TargetArn": Object { 243 | "Fn::GetAtt": Array [ 244 | "DeadLetterQueue9F481546", 245 | "Arn", 246 | ], 247 | }, 248 | }, 249 | "Environment": Object { 250 | "Variables": Object { 251 | "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", 252 | }, 253 | }, 254 | "Handler": "index.handler", 255 | "MemorySize": 128, 256 | "Role": Object { 257 | "Fn::GetAtt": Array [ 258 | "DispatcherServiceRole904BCD09", 259 | "Arn", 260 | ], 261 | }, 262 | "Runtime": "nodejs14.x", 263 | "Timeout": 5, 264 | }, 265 | "Type": "AWS::Lambda::Function", 266 | }, 267 | "DispatcherEventInvokeConfigE9191C1C": Object { 268 | "Properties": Object { 269 | "FunctionName": Object { 270 | "Ref": "DispatcherD4A12972", 271 | }, 272 | "MaximumRetryAttempts": 0, 273 | "Qualifier": "$LATEST", 274 | }, 275 | "Type": "AWS::Lambda::EventInvokeConfig", 276 | }, 277 | "DispatcherMailFeed5E0BDAD7": Object { 278 | "Properties": Object { 279 | "Endpoint": Object { 280 | "Fn::GetAtt": Array [ 281 | "DispatcherD4A12972", 282 | "Arn", 283 | ], 284 | }, 285 | "Protocol": "lambda", 286 | "RedrivePolicy": Object { 287 | "deadLetterTargetArn": Object { 288 | "Fn::GetAtt": Array [ 289 | "DeadLetterQueue9F481546", 290 | "Arn", 291 | ], 292 | }, 293 | }, 294 | "TopicArn": Object { 295 | "Ref": "MailFeedF42B1B20", 296 | }, 297 | }, 298 | "Type": "AWS::SNS::Subscription", 299 | }, 300 | "DispatcherServiceRole904BCD09": Object { 301 | "Properties": Object { 302 | "AssumeRolePolicyDocument": Object { 303 | "Statement": Array [ 304 | Object { 305 | "Action": "sts:AssumeRole", 306 | "Effect": "Allow", 307 | "Principal": Object { 308 | "Service": "lambda.amazonaws.com", 309 | }, 310 | }, 311 | ], 312 | "Version": "2012-10-17", 313 | }, 314 | "ManagedPolicyArns": Array [ 315 | Object { 316 | "Fn::Join": Array [ 317 | "", 318 | Array [ 319 | "arn:", 320 | Object { 321 | "Ref": "AWS::Partition", 322 | }, 323 | ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 324 | ], 325 | ], 326 | }, 327 | ], 328 | }, 329 | "Type": "AWS::IAM::Role", 330 | }, 331 | "DispatcherServiceRoleDefaultPolicyDA413007": Object { 332 | "Properties": Object { 333 | "PolicyDocument": Object { 334 | "Statement": Array [ 335 | Object { 336 | "Action": Array [ 337 | "logs:CreateLogGroup", 338 | "logs:CreateLogStream", 339 | "logs:PutLogEvents", 340 | ], 341 | "Effect": "Allow", 342 | "Resource": "arn:aws:logs:*:*:*", 343 | }, 344 | Object { 345 | "Action": "ses:SendRawEmail", 346 | "Effect": "Allow", 347 | "Resource": "*", 348 | }, 349 | Object { 350 | "Action": Array [ 351 | "s3:GetObject", 352 | "s3:PutObject", 353 | ], 354 | "Effect": "Allow", 355 | "Resource": Object { 356 | "Fn::Join": Array [ 357 | "", 358 | Array [ 359 | Object { 360 | "Fn::GetAtt": Array [ 361 | "Bucket83908E77", 362 | "Arn", 363 | ], 364 | }, 365 | "/*", 366 | ], 367 | ], 368 | }, 369 | }, 370 | Object { 371 | "Action": "sqs:SendMessage", 372 | "Effect": "Allow", 373 | "Resource": Object { 374 | "Fn::GetAtt": Array [ 375 | "DeadLetterQueue9F481546", 376 | "Arn", 377 | ], 378 | }, 379 | }, 380 | ], 381 | "Version": "2012-10-17", 382 | }, 383 | "PolicyName": "DispatcherServiceRoleDefaultPolicyDA413007", 384 | "Roles": Array [ 385 | Object { 386 | "Ref": "DispatcherServiceRole904BCD09", 387 | }, 388 | ], 389 | }, 390 | "Type": "AWS::IAM::Policy", 391 | }, 392 | "MailAlarmC718F8ED": Object { 393 | "DeletionPolicy": "Delete", 394 | "Properties": Object { 395 | "ComparisonOperator": "GreaterThanOrEqualToThreshold", 396 | "Dimensions": Array [ 397 | Object { 398 | "Name": "QueueName", 399 | "Value": Object { 400 | "Fn::GetAtt": Array [ 401 | "DeadLetterQueue9F481546", 402 | "QueueName", 403 | ], 404 | }, 405 | }, 406 | ], 407 | "EvaluationPeriods": 1, 408 | "MetricName": "ApproximateNumberOfMessagesVisible", 409 | "Namespace": "AWS/SQS", 410 | "Period": 300, 411 | "Statistic": "Average", 412 | "Threshold": 1, 413 | "TreatMissingData": "notBreaching", 414 | }, 415 | "Type": "AWS::CloudWatch::Alarm", 416 | "UpdateReplacePolicy": "Delete", 417 | }, 418 | "MailFeedF42B1B20": Object { 419 | "Type": "AWS::SNS::Topic", 420 | }, 421 | "ReceiptRuleSetD3CCC994": Object { 422 | "DeletionPolicy": "Delete", 423 | "Properties": Object { 424 | "RuleSetName": "MailDog-ReceiptRuleSet", 425 | }, 426 | "Type": "AWS::SES::ReceiptRuleSet", 427 | "UpdateReplacePolicy": "Delete", 428 | }, 429 | "ReceiptRuleSetRule01CA7709C": Object { 430 | "DependsOn": Array [ 431 | "BucketPolicyE9A3008A", 432 | "SpamFilterAllowSes2CDBB160", 433 | ], 434 | "Properties": Object { 435 | "Rule": Object { 436 | "Actions": Array [ 437 | Object { 438 | "LambdaAction": Object { 439 | "FunctionArn": Object { 440 | "Fn::GetAtt": Array [ 441 | "SpamFilter4A4DC48B", 442 | "Arn", 443 | ], 444 | }, 445 | "InvocationType": "RequestResponse", 446 | }, 447 | }, 448 | Object { 449 | "S3Action": Object { 450 | "BucketName": Object { 451 | "Ref": "Bucket83908E77", 452 | }, 453 | "ObjectKeyPrefix": "example.com/", 454 | "TopicArn": Object { 455 | "Ref": "MailFeedF42B1B20", 456 | }, 457 | }, 458 | }, 459 | ], 460 | "Enabled": true, 461 | "Recipients": Array [ 462 | "example.com", 463 | ], 464 | "ScanEnabled": true, 465 | "TlsPolicy": "Optional", 466 | }, 467 | "RuleSetName": Object { 468 | "Ref": "ReceiptRuleSetD3CCC994", 469 | }, 470 | }, 471 | "Type": "AWS::SES::ReceiptRule", 472 | }, 473 | "ReceiptRuleSetRule1636DD081": Object { 474 | "DependsOn": Array [ 475 | "BucketPolicyE9A3008A", 476 | "SpamFilterAllowSes2CDBB160", 477 | ], 478 | "Properties": Object { 479 | "After": Object { 480 | "Ref": "ReceiptRuleSetRule01CA7709C", 481 | }, 482 | "Rule": Object { 483 | "Actions": Array [ 484 | Object { 485 | "LambdaAction": Object { 486 | "FunctionArn": Object { 487 | "Fn::GetAtt": Array [ 488 | "SpamFilter4A4DC48B", 489 | "Arn", 490 | ], 491 | }, 492 | "InvocationType": "RequestResponse", 493 | }, 494 | }, 495 | Object { 496 | "S3Action": Object { 497 | "BucketName": Object { 498 | "Ref": "Bucket83908E77", 499 | }, 500 | "ObjectKeyPrefix": "maildog.xyz/", 501 | "TopicArn": Object { 502 | "Ref": "MailFeedF42B1B20", 503 | }, 504 | }, 505 | }, 506 | ], 507 | "Enabled": true, 508 | "Recipients": Array [ 509 | "foo@maildog.xyz", 510 | "bar@maildog.xyz", 511 | ], 512 | "ScanEnabled": true, 513 | "TlsPolicy": "Require", 514 | }, 515 | "RuleSetName": Object { 516 | "Ref": "ReceiptRuleSetD3CCC994", 517 | }, 518 | }, 519 | "Type": "AWS::SES::ReceiptRule", 520 | }, 521 | "SchedulerCFE73206": Object { 522 | "DependsOn": Array [ 523 | "SchedulerServiceRoleDefaultPolicyFA0D8235", 524 | "SchedulerServiceRole62CDA70C", 525 | ], 526 | "Properties": Object { 527 | "Code": Object { 528 | "S3Bucket": Object { 529 | "Ref": "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59S3Bucket12822632", 530 | }, 531 | "S3Key": Object { 532 | "Fn::Join": Array [ 533 | "", 534 | Array [ 535 | Object { 536 | "Fn::Select": Array [ 537 | 0, 538 | Object { 539 | "Fn::Split": Array [ 540 | "||", 541 | Object { 542 | "Ref": "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59S3VersionKey01F69698", 543 | }, 544 | ], 545 | }, 546 | ], 547 | }, 548 | Object { 549 | "Fn::Select": Array [ 550 | 1, 551 | Object { 552 | "Fn::Split": Array [ 553 | "||", 554 | Object { 555 | "Ref": "AssetParametersca8674af2868d9931918a5aee74ec64978ec3f5baba1b392bdfb306204b15f59S3VersionKey01F69698", 556 | }, 557 | ], 558 | }, 559 | ], 560 | }, 561 | ], 562 | ], 563 | }, 564 | }, 565 | "DeadLetterConfig": Object { 566 | "TargetArn": Object { 567 | "Fn::GetAtt": Array [ 568 | "DeadLetterQueue9F481546", 569 | "Arn", 570 | ], 571 | }, 572 | }, 573 | "Environment": Object { 574 | "Variables": Object { 575 | "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", 576 | "SNS_TOPIC_ARN": Object { 577 | "Ref": "MailFeedF42B1B20", 578 | }, 579 | "SQS_QUEUE_URL": Object { 580 | "Ref": "DeadLetterQueue9F481546", 581 | }, 582 | }, 583 | }, 584 | "Handler": "index.handler", 585 | "MemorySize": 128, 586 | "Role": Object { 587 | "Fn::GetAtt": Array [ 588 | "SchedulerServiceRole62CDA70C", 589 | "Arn", 590 | ], 591 | }, 592 | "Runtime": "nodejs14.x", 593 | "Timeout": 5, 594 | }, 595 | "Type": "AWS::Lambda::Function", 596 | }, 597 | "SchedulerEventInvokeConfigBC5670B2": Object { 598 | "Properties": Object { 599 | "FunctionName": Object { 600 | "Ref": "SchedulerCFE73206", 601 | }, 602 | "MaximumRetryAttempts": 0, 603 | "Qualifier": "$LATEST", 604 | }, 605 | "Type": "AWS::Lambda::EventInvokeConfig", 606 | }, 607 | "SchedulerServiceRole62CDA70C": Object { 608 | "Properties": Object { 609 | "AssumeRolePolicyDocument": Object { 610 | "Statement": Array [ 611 | Object { 612 | "Action": "sts:AssumeRole", 613 | "Effect": "Allow", 614 | "Principal": Object { 615 | "Service": "lambda.amazonaws.com", 616 | }, 617 | }, 618 | ], 619 | "Version": "2012-10-17", 620 | }, 621 | "ManagedPolicyArns": Array [ 622 | Object { 623 | "Fn::Join": Array [ 624 | "", 625 | Array [ 626 | "arn:", 627 | Object { 628 | "Ref": "AWS::Partition", 629 | }, 630 | ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 631 | ], 632 | ], 633 | }, 634 | ], 635 | }, 636 | "Type": "AWS::IAM::Role", 637 | }, 638 | "SchedulerServiceRoleDefaultPolicyFA0D8235": Object { 639 | "Properties": Object { 640 | "PolicyDocument": Object { 641 | "Statement": Array [ 642 | Object { 643 | "Action": Array [ 644 | "logs:CreateLogGroup", 645 | "logs:CreateLogStream", 646 | "logs:PutLogEvents", 647 | ], 648 | "Effect": "Allow", 649 | "Resource": "arn:aws:logs:*:*:*", 650 | }, 651 | Object { 652 | "Action": Array [ 653 | "sqs:ReceiveMessage", 654 | "sqs:DeleteMessage", 655 | ], 656 | "Effect": "Allow", 657 | "Resource": Object { 658 | "Fn::GetAtt": Array [ 659 | "DeadLetterQueue9F481546", 660 | "Arn", 661 | ], 662 | }, 663 | }, 664 | Object { 665 | "Action": "sns:Publish", 666 | "Effect": "Allow", 667 | "Resource": Object { 668 | "Ref": "MailFeedF42B1B20", 669 | }, 670 | }, 671 | Object { 672 | "Action": "sqs:SendMessage", 673 | "Effect": "Allow", 674 | "Resource": Object { 675 | "Fn::GetAtt": Array [ 676 | "DeadLetterQueue9F481546", 677 | "Arn", 678 | ], 679 | }, 680 | }, 681 | ], 682 | "Version": "2012-10-17", 683 | }, 684 | "PolicyName": "SchedulerServiceRoleDefaultPolicyFA0D8235", 685 | "Roles": Array [ 686 | Object { 687 | "Ref": "SchedulerServiceRole62CDA70C", 688 | }, 689 | ], 690 | }, 691 | "Type": "AWS::IAM::Policy", 692 | }, 693 | "SpamFilter4A4DC48B": Object { 694 | "DependsOn": Array [ 695 | "SpamFilterServiceRoleDefaultPolicy85EC4A04", 696 | "SpamFilterServiceRole799061EA", 697 | ], 698 | "Properties": Object { 699 | "Code": Object { 700 | "S3Bucket": Object { 701 | "Ref": "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406S3Bucket3EF97734", 702 | }, 703 | "S3Key": Object { 704 | "Fn::Join": Array [ 705 | "", 706 | Array [ 707 | Object { 708 | "Fn::Select": Array [ 709 | 0, 710 | Object { 711 | "Fn::Split": Array [ 712 | "||", 713 | Object { 714 | "Ref": "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406S3VersionKeyADCC52F6", 715 | }, 716 | ], 717 | }, 718 | ], 719 | }, 720 | Object { 721 | "Fn::Select": Array [ 722 | 1, 723 | Object { 724 | "Fn::Split": Array [ 725 | "||", 726 | Object { 727 | "Ref": "AssetParametersf43ff010f656880b070bbc161ff1f52c0c9dc514b60991c97d31eba8466c4406S3VersionKeyADCC52F6", 728 | }, 729 | ], 730 | }, 731 | ], 732 | }, 733 | ], 734 | ], 735 | }, 736 | }, 737 | "Environment": Object { 738 | "Variables": Object { 739 | "AWS_NODEJS_CONNECTION_REUSE_ENABLED": "1", 740 | }, 741 | }, 742 | "Handler": "index.handler", 743 | "MemorySize": 128, 744 | "Role": Object { 745 | "Fn::GetAtt": Array [ 746 | "SpamFilterServiceRole799061EA", 747 | "Arn", 748 | ], 749 | }, 750 | "Runtime": "nodejs14.x", 751 | "Timeout": 3, 752 | }, 753 | "Type": "AWS::Lambda::Function", 754 | }, 755 | "SpamFilterAllowSes2CDBB160": Object { 756 | "Properties": Object { 757 | "Action": "lambda:InvokeFunction", 758 | "FunctionName": Object { 759 | "Fn::GetAtt": Array [ 760 | "SpamFilter4A4DC48B", 761 | "Arn", 762 | ], 763 | }, 764 | "Principal": "ses.amazonaws.com", 765 | "SourceAccount": Object { 766 | "Ref": "AWS::AccountId", 767 | }, 768 | }, 769 | "Type": "AWS::Lambda::Permission", 770 | }, 771 | "SpamFilterEventInvokeConfig752E3DBC": Object { 772 | "Properties": Object { 773 | "FunctionName": Object { 774 | "Ref": "SpamFilter4A4DC48B", 775 | }, 776 | "MaximumRetryAttempts": 0, 777 | "Qualifier": "$LATEST", 778 | }, 779 | "Type": "AWS::Lambda::EventInvokeConfig", 780 | }, 781 | "SpamFilterServiceRole799061EA": Object { 782 | "Properties": Object { 783 | "AssumeRolePolicyDocument": Object { 784 | "Statement": Array [ 785 | Object { 786 | "Action": "sts:AssumeRole", 787 | "Effect": "Allow", 788 | "Principal": Object { 789 | "Service": "lambda.amazonaws.com", 790 | }, 791 | }, 792 | ], 793 | "Version": "2012-10-17", 794 | }, 795 | "ManagedPolicyArns": Array [ 796 | Object { 797 | "Fn::Join": Array [ 798 | "", 799 | Array [ 800 | "arn:", 801 | Object { 802 | "Ref": "AWS::Partition", 803 | }, 804 | ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", 805 | ], 806 | ], 807 | }, 808 | ], 809 | }, 810 | "Type": "AWS::IAM::Role", 811 | }, 812 | "SpamFilterServiceRoleDefaultPolicy85EC4A04": Object { 813 | "Properties": Object { 814 | "PolicyDocument": Object { 815 | "Statement": Array [ 816 | Object { 817 | "Action": Array [ 818 | "logs:CreateLogGroup", 819 | "logs:CreateLogStream", 820 | "logs:PutLogEvents", 821 | ], 822 | "Effect": "Allow", 823 | "Resource": "arn:aws:logs:*:*:*", 824 | }, 825 | ], 826 | "Version": "2012-10-17", 827 | }, 828 | "PolicyName": "SpamFilterServiceRoleDefaultPolicy85EC4A04", 829 | "Roles": Array [ 830 | Object { 831 | "Ref": "SpamFilterServiceRole799061EA", 832 | }, 833 | ], 834 | }, 835 | "Type": "AWS::IAM::Policy", 836 | }, 837 | }, 838 | } 839 | `; 840 | -------------------------------------------------------------------------------- /test/maildog-stack.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | expect as expectStack, 3 | SynthUtils, 4 | haveResource, 5 | countResources, 6 | objectLike, 7 | arrayWith, 8 | } from '@aws-cdk/assert'; 9 | import * as cdk from '@aws-cdk/core'; 10 | import { MailDogStack } from '../lib/maildog-stack'; 11 | 12 | describe('MailDogStack', () => { 13 | let spy: jest.SpyInstance>; 14 | 15 | beforeEach(() => { 16 | spy = jest.spyOn(console, 'warn').mockImplementation(); 17 | }); 18 | 19 | afterEach(() => { 20 | spy.mockRestore(); 21 | }); 22 | 23 | it('should match the snapshot', () => { 24 | const app = new cdk.App(); 25 | const stack = new MailDogStack(app, 'ExampleStack', { 26 | config: { 27 | domains: { 28 | 'example.com': { 29 | fallbackEmails: ['example@gmail.com'], 30 | }, 31 | 'maildog.xyz': { 32 | enabled: true, 33 | fromEmail: 'maildog', 34 | scanEnabled: true, 35 | tlsEnforced: true, 36 | fallbackEmails: [], 37 | alias: { 38 | foo: { 39 | to: ['example@gmail.com'], 40 | }, 41 | bar: { 42 | description: 'Optional description', 43 | to: ['baz@mail.com', 'bar@example.com'], 44 | }, 45 | baz: {}, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }); 51 | 52 | expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); 53 | }); 54 | 55 | it('should set the ReceiptRuleSet name based on the stackName', () => { 56 | const createMaildogStack = (stackName?: string) => { 57 | const app = new cdk.App(); 58 | const stack = new MailDogStack(app, 'ExampleStack', { 59 | config: { 60 | domains: { 61 | 'example.com': { 62 | fallbackEmails: ['foo@exmaple.com', 'bar@example.com'], 63 | }, 64 | }, 65 | }, 66 | stackName, 67 | }); 68 | 69 | return stack; 70 | }; 71 | 72 | expectStack(createMaildogStack()).to( 73 | haveResource('AWS::SES::ReceiptRuleSet', { 74 | RuleSetName: 'MailDog-ReceiptRuleSet', 75 | }), 76 | ); 77 | expectStack(createMaildogStack('Foo-bar')).to( 78 | haveResource('AWS::SES::ReceiptRuleSet', { 79 | RuleSetName: 'Foo-bar-ReceiptRuleSet', 80 | }), 81 | ); 82 | }); 83 | 84 | it('should setup the ReceiptRule based on the config', () => { 85 | const app = new cdk.App(); 86 | const stack = new MailDogStack(app, 'ExampleStack', { 87 | config: { 88 | domains: { 89 | 'maildog.dev': { 90 | fallbackEmails: ['foo@example.com'], 91 | alias: { 92 | foo: { 93 | description: 'Something here', 94 | to: ['foobar@maildog.example.com'], 95 | }, 96 | }, 97 | }, 98 | 'testing.maildog.dev': { 99 | enabled: true, 100 | fromEmail: 'notifications', 101 | scanEnabled: true, 102 | tlsEnforced: true, 103 | fallbackEmails: [], 104 | alias: { 105 | foo: { 106 | description: 'Testing', 107 | to: ['bar@example.com', 'baz@example.com'], 108 | }, 109 | bar: { 110 | to: [], 111 | }, 112 | baz: { 113 | description: 'No to', 114 | }, 115 | }, 116 | }, 117 | 'example.com': { 118 | alias: Object.fromEntries( 119 | [...Array(101).keys()].map((i) => [ 120 | `${i + 1}`, 121 | { 122 | to: [`${i + 1}@maildog.dev`], 123 | }, 124 | ]), 125 | ), 126 | }, 127 | }, 128 | }, 129 | }); 130 | 131 | expectStack(stack).to(countResources('AWS::SES::ReceiptRule', 4)); 132 | expectStack(stack).to( 133 | haveResource('AWS::SES::ReceiptRule', { 134 | Rule: objectLike({ 135 | Actions: arrayWith({ 136 | S3Action: objectLike({ 137 | ObjectKeyPrefix: 'maildog.dev/', 138 | }), 139 | }), 140 | Enabled: true, 141 | ScanEnabled: true, 142 | TlsPolicy: 'Optional', 143 | Recipients: ['maildog.dev'], 144 | }), 145 | }), 146 | ); 147 | expectStack(stack).to( 148 | haveResource('AWS::SES::ReceiptRule', { 149 | Rule: objectLike({ 150 | Actions: arrayWith({ 151 | S3Action: objectLike({ 152 | ObjectKeyPrefix: 'testing.maildog.dev/', 153 | }), 154 | }), 155 | Enabled: true, 156 | ScanEnabled: true, 157 | TlsPolicy: 'Require', 158 | Recipients: ['foo@testing.maildog.dev'], 159 | }), 160 | }), 161 | ); 162 | expectStack(stack).to( 163 | haveResource('AWS::SES::ReceiptRule', { 164 | Rule: objectLike({ 165 | Actions: arrayWith({ 166 | S3Action: objectLike({ 167 | ObjectKeyPrefix: 'example.com/', 168 | }), 169 | }), 170 | Enabled: true, 171 | ScanEnabled: true, 172 | TlsPolicy: 'Optional', 173 | Recipients: [...Array(100).keys()].map((i) => `${i + 1}@example.com`), 174 | }), 175 | }), 176 | ); 177 | expectStack(stack).to( 178 | haveResource('AWS::SES::ReceiptRule', { 179 | Rule: objectLike({ 180 | Actions: arrayWith({ 181 | S3Action: objectLike({ 182 | ObjectKeyPrefix: 'example.com/', 183 | }), 184 | }), 185 | Enabled: true, 186 | ScanEnabled: true, 187 | TlsPolicy: 'Optional', 188 | Recipients: ['101@example.com'], 189 | }), 190 | }), 191 | ); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | "declaration": false, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "noImplicitThis": true, 12 | "alwaysStrict": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "strictPropertyInitialization": true, 22 | "typeRoots": ["./node_modules/@types"] 23 | }, 24 | "exclude": ["cdk.out", "packages"] 25 | } 26 | -------------------------------------------------------------------------------- /types/aws-lambda-ses-forwarder.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'aws-lambda-ses-forwarder'; 2 | --------------------------------------------------------------------------------