├── .dockerignore ├── .github ├── release-drafter.yml ├── renovate.json └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── deploy.yml │ ├── lint.yml │ ├── release-drafter.yml │ ├── release.yml │ ├── scorecards.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── bff ├── README.md ├── config │ ├── config.go │ ├── config_test.go │ ├── plugin.go │ └── plugin_test.go ├── go.mod ├── go.sum ├── idgen │ └── id.go ├── main.go ├── request │ ├── aws.go │ └── sign.go ├── testdata │ ├── config.yaml │ └── error.yaml ├── transport │ └── rest │ │ ├── ginutil │ │ └── util.go │ │ ├── misc │ │ ├── config.go │ │ ├── info.go │ │ ├── ping.go │ │ └── robots.go │ │ ├── plugin │ │ ├── err.go │ │ ├── plugin.go │ │ └── plugin_test.go │ │ ├── proxy │ │ ├── mailbox.go │ │ ├── proxy.go │ │ └── proxy_test.go │ │ └── router.go └── types │ ├── email.go │ └── plugin.go ├── cloudflare ├── .prettierrc ├── functions │ ├── _middleware.ts │ ├── config.ts │ ├── info.ts │ ├── ping.ts │ ├── plugins │ │ └── invoke.ts │ ├── proxy.ts │ ├── tsconfig.json │ └── web │ │ └── [[catchall]].ts ├── package-lock.json ├── package.json ├── public │ └── robots.txt └── src │ ├── config.ts │ ├── email.ts │ └── plugin.ts ├── docs ├── plugin │ └── schema.example.json └── state.md └── web ├── .prettierignore ├── .prettierrc ├── components.json ├── eslint.config.ts ├── index.html ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── App.tsx ├── components │ ├── Sidebar.tsx │ ├── emails │ │ ├── DraftEmailsTabs.tsx │ │ ├── EmailDraft.tsx │ │ ├── EmailMenuBar.tsx │ │ ├── EmailName.tsx │ │ ├── EmailTableRow.tsx │ │ ├── EmailTableView.tsx │ │ └── FullScreenContent.tsx │ ├── inputs │ │ ├── EmailAddressInput.tsx │ │ ├── EmailQuoteNode.tsx │ │ ├── RichTextEditor.css │ │ ├── RichTextEditor.tsx │ │ ├── TextInput.tsx │ │ ├── icons │ │ │ ├── Bars3BottomCenterIcon.tsx │ │ │ ├── BoldIcon.tsx │ │ │ ├── IconProps.ts │ │ │ ├── ItalicIcon.tsx │ │ │ ├── ListNumberIcon.tsx │ │ │ ├── StrikeThrough.tsx │ │ │ └── Underline.tsx │ │ ├── plugins │ │ │ ├── AutoLinkPlugin.tsx │ │ │ ├── CodeHighlightPlugin.tsx │ │ │ ├── ListMaxIndentLevelPlugin.tsx │ │ │ └── ToolbarPlugin.tsx │ │ └── themes │ │ │ └── LexicalTheme.ts │ ├── lib │ │ └── utils.ts │ └── ui │ │ └── sonner.tsx ├── contexts │ ├── ConfigContext.ts │ ├── DraftEmailContext.test.tsx │ └── DraftEmailContext.ts ├── hooks │ ├── useIsInViewport.ts │ ├── useOutsideClick.ts │ └── useThrottled.ts ├── index.css ├── main.tsx ├── pages │ ├── EmailList.tsx │ ├── EmailRawView.tsx │ ├── EmailRoot.tsx │ ├── EmailView.tsx │ └── Root.tsx ├── preflight.css ├── services │ ├── config.ts │ ├── emails.ts │ ├── info.ts │ ├── plugins.ts │ └── threads.ts ├── utils │ ├── elements.ts │ ├── emails.test.ts │ ├── emails.tsx │ └── time.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | dist 3 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: "v$RESOLVED_VERSION" 2 | tag-template: "v$RESOLVED_VERSION" 3 | categories: 4 | - title: "❗ Security" 5 | labels: 6 | - "security" 7 | - title: "🚀 Features" 8 | labels: 9 | - "feature" 10 | - "enhancement" 11 | - title: "🐛 Bug Fixes" 12 | labels: 13 | - "fix" 14 | - "bugfix" 15 | - "bug" 16 | - title: "🧰 Maintenance" 17 | label: "chore" 18 | - title: "⬆️ Dependencies" 19 | collapse-after: 3 20 | labels: 21 | - "dependencies" 22 | - title: "📝 Documentation" 23 | collapse-after: 3 24 | labels: 25 | - "docs" 26 | - "documentation" 27 | exclude-labels: 28 | - "skip-changelog" 29 | change-title-escapes: '\<*_&' 30 | version-resolver: 31 | major: 32 | labels: 33 | - "major" 34 | minor: 35 | labels: 36 | - "minor" 37 | patch: 38 | labels: 39 | - "patch" 40 | default: patch 41 | template: | 42 | ## Changes 43 | 44 | $CHANGES 45 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>harryzcy/renovate-config" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: ["main"] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ["main"] 20 | schedule: 21 | - cron: "0 0 * * 1" 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-24.04 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: ["go", "javascript", "typescript"] 39 | # CodeQL supports [ $supported-codeql-languages ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Harden Runner 44 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 45 | with: 46 | egress-policy: audit 47 | 48 | - name: Checkout repository 49 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 61 | # If this step fails, then you should remove it and run the build manually (see below) 62 | - name: Autobuild 63 | uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 64 | 65 | # ℹ️ Command-line programs to run using the OS shell. 66 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 67 | 68 | # If the Autobuild fails above, remove it and uncomment the following three lines. 69 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 70 | 71 | # - run: | 72 | # echo "Run, Build Application using script" 73 | # ./location_of_script_within_repo/buildscript.sh 74 | 75 | - name: Perform CodeQL Analysis 76 | uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 77 | with: 78 | category: "/language:${{matrix.language}}" 79 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: "Dependency Review" 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-24.04 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 21 | with: 22 | egress-policy: audit 23 | 24 | - name: "Checkout Repository" 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - name: "Dependency Review" 27 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-24.04 12 | if: github.repository_owner == 'harryzcy' 13 | permissions: 14 | contents: read 15 | deployments: write 16 | name: Publish to Cloudflare Pages 17 | steps: 18 | - name: Harden Runner 19 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 20 | with: 21 | disable-sudo: true 22 | disable-telemetry: true 23 | egress-policy: block 24 | allowed-endpoints: > 25 | api.cloudflare.com:443 26 | api.github.com:443 27 | github.com:443 28 | nodejs.org:443 29 | objects.githubusercontent.com:443 30 | registry.npmjs.org:443 31 | sparrow.cloudflare.com:443 32 | 33 | - name: Checkout 34 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: Setup Node 39 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 40 | with: 41 | node-version: 23 42 | check-latest: true 43 | 44 | - name: Extract branch name 45 | shell: bash 46 | run: | 47 | if [[ "${GITHUB_REF}" =~ ^refs/tags/ ]]; then 48 | echo "branch=main" >> "$GITHUB_OUTPUT" 49 | else 50 | echo "branch=${GITHUB_REF#refs/heads/}" >> "$GITHUB_OUTPUT" 51 | fi 52 | id: extract_branch 53 | 54 | - name: Build 55 | run: | 56 | make build-cloudflare 57 | 58 | - name: Publish to Cloudflare Pages 59 | uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 60 | if: ${{ github.actor != 'dependabot[bot]' }} 61 | with: 62 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 63 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 64 | command: pages deploy --project-name mailbox-browser --branch ${{ steps.extract_branch.outputs.branch }} dist 65 | workingDirectory: cloudflare 66 | wranglerVersion: "4" 67 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags-ignore: 8 | - "v*" 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | super-linter: 18 | name: Super Linter 19 | uses: harryzcy/github-actions/.github/workflows/linter.yml@main 20 | 21 | go-lint: 22 | name: Go Lint 23 | uses: harryzcy/github-actions/.github/workflows/golangci-lint.yml@main 24 | with: 25 | working-directory: bff 26 | 27 | prettier: 28 | name: Prettier 29 | uses: harryzcy/github-actions/.github/workflows/prettier.yml@main 30 | with: 31 | working-directory: web 32 | 33 | eslint: 34 | name: ESLint 35 | uses: harryzcy/github-actions/.github/workflows/npm-lint.yml@main 36 | with: 37 | working-directory: web 38 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags-ignore: 8 | - "v*" 9 | pull_request: 10 | types: 11 | - opened 12 | - reopened 13 | - synchronize 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | update: 20 | name: Generate Notes 21 | permissions: 22 | contents: write 23 | pull-requests: write 24 | uses: harryzcy/github-actions/.github/workflows/release-drafter.yml@main 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | release: 13 | name: Release on GitHub 14 | permissions: 15 | contents: write 16 | uses: harryzcy/github-actions/.github/workflows/release.yml@main 17 | 18 | release-docker: 19 | name: Release on Docker Hub 20 | if: github.repository_owner == 'harryzcy' 21 | runs-on: ubuntu-24.04 22 | permissions: 23 | packages: write 24 | steps: 25 | - name: Harden Runner 26 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 27 | with: 28 | disable-sudo: true 29 | disable-telemetry: true 30 | egress-policy: audit 31 | 32 | - name: Checkout 33 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 34 | 35 | - name: Get build variables 36 | run: | 37 | # shellcheck disable=SC2129 38 | echo "BUILD_COMMIT=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" 39 | echo "BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_ENV" 40 | echo "BUILD_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV" 41 | 42 | - name: Check build variables 43 | run: | 44 | echo "$BUILD_COMMIT" 45 | echo "$BUILD_DATE" 46 | echo "$BUILD_VERSION" 47 | 48 | - name: Docker meta 49 | id: meta 50 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 51 | with: 52 | images: | 53 | harryzcy/mailbox-browser 54 | ghcr.io/harryzcy/mailbox-browser 55 | tags: | 56 | type=semver,pattern={{version}} 57 | type=semver,pattern={{major}}.{{minor}} 58 | type=semver,pattern={{major}} 59 | type=sha 60 | 61 | - name: Set up QEMU 62 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 63 | 64 | - name: Set up Docker Buildx 65 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 66 | 67 | - name: Login to DockerHub 68 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 69 | with: 70 | username: ${{ secrets.DOCKERHUB_USERNAME }} 71 | password: ${{ secrets.DOCKERHUB_TOKEN }} 72 | 73 | - name: Login to GitHub Container Registry 74 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 75 | with: 76 | registry: ghcr.io 77 | username: ${{ github.repository_owner }} 78 | password: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | - name: Build and push 81 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 82 | with: 83 | context: . 84 | build-args: | 85 | BUILD_DATE=${{ env.BUILD_DATE }} 86 | BUILD_COMMIT=${{ env.BUILD_COMMIT }} 87 | BUILD_VERSION=${{ env.BUILD_VERSION }} 88 | platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 89 | push: true 90 | tags: ${{ steps.meta.outputs.tags }} 91 | labels: ${{ steps.meta.outputs.labels }} 92 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: "20 7 * * 2" 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-24.04 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: Harden Runner 34 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 35 | with: 36 | egress-policy: audit 37 | 38 | - name: "Checkout code" 39 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 40 | with: 41 | persist-credentials: false 42 | 43 | - name: "Run analysis" 44 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 45 | with: 46 | results_file: results.sarif 47 | results_format: sarif 48 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 49 | # - you want to enable the Branch-Protection check on a *public* repository, or 50 | # - you are installing Scorecards on a *private* repository 51 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 52 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 53 | 54 | # Public repositories: 55 | # - Publish results to OpenSSF REST API for easy access by consumers 56 | # - Allows the repository to include the Scorecard badge. 57 | # - See https://github.com/ossf/scorecard-action#publishing-results. 58 | # For private repositories: 59 | # - `publish_results` will always be set to `false`, regardless 60 | # of the value entered here. 61 | publish_results: true 62 | 63 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 64 | # format to the repository Actions tab. 65 | - name: "Upload artifact" 66 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 67 | with: 68 | name: SARIF file 69 | path: results.sarif 70 | retention-days: 5 71 | 72 | # Upload the results to GitHub's code scanning dashboard. 73 | - name: "Upload to code-scanning" 74 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 75 | with: 76 | sarif_file: results.sarif 77 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | tags-ignore: 8 | - "v*" 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | go-test: 18 | name: Go Tests 19 | permissions: 20 | id-token: write 21 | contents: read 22 | uses: harryzcy/github-actions/.github/workflows/go.yml@main 23 | with: 24 | working-directory: bff 25 | latest: 1 26 | 27 | jest-test: 28 | name: Jest Tests 29 | runs-on: ubuntu-24.04 30 | strategy: 31 | matrix: 32 | node-version: [20.x, 22.x, 23.x] 33 | permissions: 34 | id-token: write 35 | defaults: 36 | run: 37 | working-directory: web 38 | steps: 39 | - name: Harden Runner 40 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 41 | with: 42 | disable-sudo: true 43 | disable-telemetry: true 44 | egress-policy: block 45 | allowed-endpoints: > 46 | api.codecov.io:443 47 | api.github.com:443 48 | cli.codecov.io:443 49 | codecov.io:443 50 | github.com:443 51 | ingest.codecov.io:443 52 | keybase.io:443 53 | nodejs.org:443 54 | registry.npmjs.org:443 55 | storage.googleapis.com:443 56 | uploader.codecov.io:443 57 | objects.githubusercontent.com:443 58 | 59 | - name: Checkout 60 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 61 | 62 | - name: Setup Node 63 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 64 | with: 65 | node-version: ${{ matrix.node-version }} 66 | 67 | - name: Install dependencies 68 | run: npm ci 69 | 70 | - name: Run tests 71 | run: npm test 72 | 73 | - name: Upload coverage to Codecov 74 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 75 | with: 76 | use_oidc: true 77 | 78 | docker: 79 | name: Docker Build 80 | runs-on: ${{ startsWith(matrix.platforms, 'arm64') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} 81 | strategy: 82 | matrix: 83 | platforms: [amd64, arm64/v8] 84 | steps: 85 | - name: Harden Runner 86 | uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 87 | with: 88 | disable-sudo: true 89 | disable-telemetry: true 90 | egress-policy: block 91 | allowed-endpoints: > 92 | auth.docker.io:443 93 | github.com:443 94 | production.cloudflare.docker.com:443 95 | proxy.golang.org:443 96 | registry-1.docker.io:443 97 | registry.npmjs.org:443 98 | storage.googleapis.com:443 99 | 100 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 101 | 102 | - name: Set up Docker Buildx 103 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 104 | 105 | - name: Get build variables 106 | run: | 107 | # shellcheck disable=SC2129 108 | echo "BUILD_COMMIT=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" 109 | echo "BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_ENV" 110 | echo "BUILD_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV" 111 | 112 | - name: Check build variables 113 | run: | 114 | echo "$BUILD_COMMIT" 115 | echo "$BUILD_DATE" 116 | echo "$BUILD_VERSION" 117 | 118 | - name: Build docker image 119 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 120 | with: 121 | context: . 122 | build-args: | 123 | BUILD_DATE=${{ env.BUILD_DATE }} 124 | BUILD_COMMIT=${{ env.BUILD_COMMIT }} 125 | BUILD_VERSION=${{ env.BUILD_VERSION }} 126 | platforms: linux/${{ matrix.platforms }} 127 | push: false 128 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | coverage 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # wrangler 28 | .wrangler 29 | 30 | # env 31 | .env 32 | 33 | # build info 34 | /web/src/utils/info.ts 35 | /cloudflare/src/buildInfo.ts 36 | 37 | # config 38 | bff/config.yaml 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24.3@sha256:81bf5927dc91aefb42e2bc3a5abdbe9bb3bae8ba8b107e2a4cf43ce3402534c6 AS bff-builder 2 | 3 | ARG BUILD_VERSION 4 | ARG BUILD_COMMIT 5 | ARG BUILD_DATE 6 | 7 | WORKDIR /go/src/bff 8 | COPY ./bff ./ 9 | 10 | RUN set -ex && \ 11 | go mod download && \ 12 | go build \ 13 | -ldflags=" \ 14 | -X 'github.com/harryzcy/mailbox-browser/bff/transport/rest/misc.version=${BUILD_VERSION}' \ 15 | -X 'github.com/harryzcy/mailbox-browser/bff/transport/rest/misc.commit=${BUILD_COMMIT}' \ 16 | -X 'github.com/harryzcy/mailbox-browser/bff/transport/rest/misc.buildDate=${BUILD_DATE}' \ 17 | -w -s" \ 18 | -o /bin/bff 19 | 20 | FROM --platform=$BUILDPLATFORM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS web-builder 21 | 22 | ARG BUILD_VERSION 23 | 24 | WORKDIR /app 25 | 26 | COPY web ./ 27 | RUN npm ci && \ 28 | echo "export const browserVersion = \"${BUILD_VERSION}\"" > src/utils/info.ts && \ 29 | npm run build 30 | 31 | FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715 32 | 33 | RUN addgroup -S bff && adduser -S bff -G bff 34 | USER bff 35 | 36 | COPY --from=bff-builder --chown=bff:bff /bin/bff /bin/bff 37 | COPY --from=web-builder --chown=bff:bff /app/dist /bin/dist 38 | 39 | ENV MODE=prod 40 | ENV STATIC_DIR=/bin/dist 41 | ENV PORT=8070 42 | 43 | HEALTHCHECK --interval=5s --timeout=3s --retries=3 CMD wget -qO- http://localhost:8070/ping || exit 1 44 | 45 | CMD ["/bin/bff"] 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chongyi Zheng 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_IMAGE = "mailbox-browser" 2 | 3 | BUILD_COMMIT = $(shell git rev-parse --short HEAD) 4 | BUILD_DATE = $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 5 | BUILD_VERSION = $(shell git describe --tags --always) 6 | 7 | ARG_BUILD_COMMIT = --build-arg BUILD_COMMIT=$(BUILD_COMMIT) 8 | ARG_BUILD_DATE = --build-arg BUILD_DATE=$(BUILD_DATE) 9 | ARG_BUILD_VERSION = --build-arg BUILD_VERSION=$(BUILD_VERSION) 10 | DOCKER_BUILD_ARGS = $(ARG_BUILD_COMMIT) $(ARG_BUILD_DATE) $(ARG_BUILD_VERSION) 11 | 12 | WRANGLER_ARGS := $(if $(CF_PROJECT_NAME),--project-name $(CF_PROJECT_NAME),) 13 | 14 | .PHONY: all 15 | all: web docker 16 | 17 | .PHONY: build-web 18 | build-web: 19 | @echo "Building web..." 20 | @echo "export const browserVersion = \"$(BUILD_VERSION)\"" > web/src/utils/info.ts 21 | @cd web && npm ci && npm run build 22 | 23 | .PHONY: build-docker 24 | build-docker: 25 | @echo "Building docker..." 26 | @echo "Build commit: $(DOCKER_BUILD_ARGS)" 27 | @docker build $(DOCKER_BUILD_ARGS) -t $(DOCKER_IMAGE) . 28 | 29 | .PHONY: build-cloudflare 30 | build-cloudflare: build-web 31 | @cp -r web/dist cloudflare/ 32 | @cp -r cloudflare/public/* cloudflare/dist 33 | @echo "export const BUILD_VERSION = \"$(BUILD_VERSION)\"" > cloudflare/src/buildInfo.ts 34 | @echo "export const BUILD_COMMIT = \"$(BUILD_COMMIT)\"" >> cloudflare/src/buildInfo.ts 35 | @echo "export const BUILD_DATE = \"$(BUILD_DATE)\"" >> cloudflare/src/buildInfo.ts 36 | @cd cloudflare && npm ci 37 | 38 | .PHONY: cloudflare 39 | cloudflare: build-cloudflare 40 | @echo "Deploying to Cloudflare..." 41 | @cd cloudflare && npx wrangler pages deploy dist $(WRANGLER_ARGS) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mailbox-browser 2 | 3 | Web Interface for Mailbox. 4 | 5 | ## Usage 6 | 7 | ### Docker 8 | 9 | ```shell 10 | docker run --env AWS_ACCESS_KEY_ID= \ 11 | --env AWS_SECRET_ACCESS_KEY= \ 12 | --env AWS_REGION= \ 13 | --env AWS_API_GATEWAY_ENDPOINT= \ 14 | harryzcy/mailbox-browser 15 | ``` 16 | 17 | ### Cloudflare Pages & Pages Functions 18 | 19 | 1. Clone the repository 20 | 1. Create [Cloudflare project](https://developers.cloudflare.com/pages/get-started/guide/) 21 | 1. Configure correct environment variables according to [this](#environment-variables) section 22 | 1. Run `make cloudflare` 23 | 24 | Replace the environment variables with respective values. 25 | 26 | Two forms of authentication is supported when using Cloudflare for deployments: 27 | 28 | - Basic Auth: Providing `AUTH_BASIC_USER` and `AUTH_BASIC_PASS` environmental variable will enabled HTTP basic auth for all routes. 29 | - Forward Auth: This method delegates authentication to an external service, whose URL address is defined by `AUTH_FORWARD_ADDRESS`. 30 | 31 | For every request received, the middleware will send a request with the same header to the external service. If the response has a 2XX code, then the access is granted and the original request is performed. Otherwise, the response from the external service is returned. 32 | 33 | Forward Auth will take precedence over basic auth. So if `AUTH_FORWARD_ADDRESS` is defined, Basic Auth won't be performed. 34 | 35 | ## Environment Variables 36 | 37 | During runtime: 38 | 39 | - `AWS_ACCESS_KEY_ID`: AWS access key id 40 | - `AWS_SECRET_ACCESS_KEY`: AWS secret access key 41 | - `AWS_REGION`: AWS region code 42 | - `AWS_API_GATEWAY_ENDPOINT`: AWS API Gateway endpoint 43 | - `EMAIL_ADDRESSES`: a comma-separated list of email addresses/domains to send email from (required for replying emails) 44 | - `PROXY_ENABLE` (optional): whether to proxy email images, must be `true` (default) or `false` 45 | - `IMAGES_AUTO_LOAD` (optional): whether to automatically load images, must be `true` (default) or `false` 46 | - `AUTH_BASIC_USER`: Basic Auth username (only available using Cloudflare Pages) 47 | - `AUTH_BASIC_PASS`: Basic Auth password (only available using Cloudflare Pages) 48 | - `AUTH_FORWARD_ADDRESS`: Forward Auth address (only available using Cloudflare Pages) 49 | 50 | During deployment: 51 | 52 | - `CF_PROJECT_NAME`: The project name for Cloudflare Pages deployment. When this is set, `wrangler` won't prompt to select project every time. 53 | 54 | ## Components 55 | 56 | | Directory | Description | 57 | | --------- | ----------- | 58 | | bff | Backend for frontend | 59 | | cloudflare | Cloudflare Pages deployment | 60 | | web | Web frontend | 61 | 62 | ## Screenshots 63 | 64 | | Dark mode | Light mode | 65 | |:---------:|:-----------:| 66 | | ![Screenshot Dark Mode](https://github.com/harryzcy/mailbox-browser/assets/37034805/b77a6c40-c6c1-4dd8-98de-2add697b26f9) | ![Screenshot Light Mode](https://github.com/harryzcy/mailbox-browser/assets/37034805/ce9ab42c-923a-4b03-8ee4-bcdc9d4b72ed) | 67 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To report a security issue through email, please report to `security@zcy.dev`. Please include the repository name **"mailbox-browser"** anywhere in your email. 6 | 7 | ### Private Reporting on GitHub 8 | 9 | Private vulnerability reporting on GitHub is enabled for this repository. 10 | -------------------------------------------------------------------------------- /bff/README.md: -------------------------------------------------------------------------------- 1 | # Backend for Frontend 2 | 3 | This backend layer proxies [core mailbox](https://github.com/harryzcy/mailbox) APIs and handles relevant authentications required by AWS API Gateway. 4 | 5 | ## Environment variables 6 | 7 | - `MODE`: dev (default) or prod 8 | - `LOG_PATH`: path to the log file 9 | - `STATIC_DIR`: path to frontend build file directory 10 | - `MAILBOX_URL`: url of mailbox service hosted on AWS 11 | -------------------------------------------------------------------------------- /bff/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/spf13/viper" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | var ( 14 | StaticDir string 15 | IndexHTML string 16 | 17 | AWSRegion string 18 | AWSAccessKeyID string 19 | AWSSecretAccessKey string 20 | AWSAPIGatewayEndpoint string 21 | 22 | EmailAddresses []string 23 | ProxyEnable bool 24 | ImagesAutoLoad bool 25 | 26 | PluginConfigs []string // comma separated list of plugin config urls 27 | ) 28 | 29 | type Hook struct { 30 | Type string `json:"type"` 31 | Name string `json:"name"` 32 | DisplayName string `json:"displayName"` 33 | } 34 | 35 | var ( 36 | configName = "config" 37 | ) 38 | 39 | func Init(logger *zap.Logger) error { 40 | err := load(logger) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | err = LoadPluginConfigs() 46 | if err != nil { 47 | logger.Error("Fatal error loading plugin configs", zap.Error(err)) 48 | return err 49 | } 50 | return nil 51 | } 52 | 53 | func load(logger *zap.Logger) error { 54 | v := viper.New() 55 | v.SetConfigName(configName) 56 | v.SetConfigType("yaml") 57 | v.AddConfigPath(".") 58 | 59 | err := v.ReadInConfig() 60 | if err != nil { 61 | var notFound viper.ConfigFileNotFoundError 62 | if errors.As(err, ¬Found) { 63 | logger.Info("No config file found, using environment variables") 64 | } else { 65 | logger.Error("Fatal error config file", zap.Error(err)) 66 | return err 67 | } 68 | } 69 | 70 | StaticDir = getString(v, "static.dir", "STATIC_DIR") 71 | IndexHTML = filepath.Join(StaticDir, "index.html") 72 | 73 | AWSRegion = getString(v, "aws.region", "AWS_REGION") 74 | AWSAccessKeyID = getString(v, "aws.accessKeyID", "AWS_ACCESS_KEY_ID") 75 | AWSSecretAccessKey = getString(v, "aws.secretAccessKey", "AWS_SECRET_ACCESS_KEY") 76 | AWSAPIGatewayEndpoint = strings.TrimSuffix(getString(v, "aws.apiGateway.endpoint", "AWS_API_GATEWAY_ENDPOINT"), "/") 77 | 78 | // comma separated list of email addresses or domains, required for email replies 79 | EmailAddresses = strings.Split(getString(v, "email.addresses", "EMAIL_ADDRESSES"), ",") 80 | 81 | ProxyEnable = getBool(v, "proxy.enable", "ENABLE_PROXY", true) 82 | ImagesAutoLoad = getBool(v, "images.autoLoad", "IMAGES_AUTO_LOAD", true) 83 | PluginConfigs = getStringSlice(v, "plugin.configs", "PLUGIN_CONFIGS") 84 | 85 | return nil 86 | } 87 | 88 | func getString(v *viper.Viper, key, env string) string { 89 | if value, ok := os.LookupEnv(env); ok { 90 | return value 91 | } 92 | if v.IsSet(key) { 93 | return v.GetString(key) 94 | } 95 | return "" 96 | } 97 | 98 | func getBool(v *viper.Viper, key, env string, defaultValue bool) bool { 99 | if value, ok := os.LookupEnv(env); ok { 100 | return value == "true" 101 | } 102 | 103 | if v.IsSet(key) { 104 | return v.GetBool(key) 105 | } 106 | 107 | return defaultValue 108 | } 109 | 110 | func getStringSlice(v *viper.Viper, key, env string) []string { 111 | if value, ok := os.LookupEnv(env); ok { 112 | value = strings.TrimSuffix(value, ",") 113 | return strings.Split(value, ",") 114 | } 115 | if v.IsSet(key) { 116 | return v.GetStringSlice(key) 117 | } 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /bff/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/spf13/viper" 9 | "github.com/stretchr/testify/assert" 10 | "go.uber.org/zap" 11 | ) 12 | 13 | func chdir(dir string) { 14 | wd, err := os.Getwd() 15 | if err != nil { 16 | panic(err) 17 | } 18 | for !strings.HasSuffix(wd, "/bff") { 19 | err = os.Chdir("..") 20 | if err != nil { 21 | panic(err) 22 | } 23 | 24 | wd, err = os.Getwd() 25 | if err != nil { 26 | panic(err) 27 | } 28 | } 29 | 30 | if dir != "" { 31 | err = os.Chdir(dir) 32 | if err != nil { 33 | panic(err) 34 | } 35 | } 36 | } 37 | 38 | func TestLoad(t *testing.T) { 39 | chdir("testdata") 40 | defer chdir("") 41 | 42 | logger, err := zap.NewDevelopment() 43 | assert.NoError(t, err) 44 | 45 | err = load(logger) 46 | assert.NoError(t, err) 47 | assert.Equal(t, "/static", StaticDir) 48 | assert.Equal(t, "/static/index.html", IndexHTML) 49 | assert.Equal(t, "us-west-2", AWSRegion) 50 | assert.Equal(t, "example-key-id", AWSAccessKeyID) 51 | assert.Equal(t, "example", AWSSecretAccessKey) 52 | assert.Equal(t, "https://example.com", AWSAPIGatewayEndpoint) 53 | assert.Equal(t, []string{"example.com", "example.org"}, EmailAddresses) 54 | assert.True(t, ProxyEnable) 55 | assert.Equal(t, "https://example.com/plugin1", PluginConfigs[0]) 56 | assert.Equal(t, "https://example.com/plugin2", PluginConfigs[1]) 57 | } 58 | 59 | func TestLoad_NoFile(t *testing.T) { 60 | chdir("config") 61 | defer chdir("") 62 | 63 | logger, err := zap.NewDevelopment() 64 | assert.NoError(t, err) 65 | 66 | err = load(logger) 67 | assert.NoError(t, err) 68 | assert.Equal(t, "", StaticDir) 69 | assert.Equal(t, "", AWSRegion) 70 | assert.Equal(t, "", AWSAccessKeyID) 71 | assert.Equal(t, "", AWSSecretAccessKey) 72 | assert.Equal(t, "", AWSAPIGatewayEndpoint) 73 | assert.Equal(t, []string{""}, EmailAddresses) 74 | assert.True(t, ProxyEnable) 75 | } 76 | 77 | func TestLoad_ViperError(t *testing.T) { 78 | chdir("testdata") 79 | configName = "error" 80 | defer chdir("") 81 | defer func() { 82 | configName = "config" 83 | }() 84 | 85 | logger, err := zap.NewDevelopment() 86 | assert.NoError(t, err) 87 | 88 | err = load(logger) 89 | assert.Error(t, err) 90 | } 91 | 92 | func TestGetString(t *testing.T) { 93 | v := viper.New() 94 | v.Set("key", "value") 95 | assert.Equal(t, "value", getString(v, "key", "")) 96 | 97 | err := os.Setenv("key2", "value2") 98 | assert.NoError(t, err) 99 | assert.Equal(t, "value2", getString(v, "key2", "key2")) 100 | } 101 | 102 | func TestGetBool(t *testing.T) { 103 | v := viper.New() 104 | v.Set("key", true) 105 | assert.True(t, getBool(v, "key", "", false)) 106 | 107 | err := os.Setenv("key2", "true") 108 | assert.NoError(t, err) 109 | assert.True(t, getBool(v, "key2", "key2", false)) 110 | 111 | err = os.Setenv("key3", "false") 112 | assert.NoError(t, err) 113 | assert.False(t, getBool(v, "key3", "key3", true)) 114 | 115 | assert.True(t, getBool(v, "key4", "key4", true)) 116 | } 117 | -------------------------------------------------------------------------------- /bff/config/plugin.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | const ( 11 | DefaultHTTPTimeout = 10 * time.Second 12 | ) 13 | 14 | var ( 15 | // Plugins is a list of all plugins, and may be nil if plugin config is invalid 16 | Plugins []*Plugin 17 | 18 | // ErrPluginConfigURLMismatch is returned when the plugin config url does not match 19 | // the url the config was loaded from 20 | ErrPluginConfigURLMismatch = errors.New("plugin config url mismatch") 21 | ) 22 | 23 | // Plugin represents a plugin config. 24 | type Plugin struct { 25 | SchemaVersion string `json:"schemaVersion"` 26 | Name string `json:"name"` 27 | DisplayName string `json:"displayName"` 28 | Description string `json:"description"` 29 | Author string `json:"author"` 30 | Homepage string `json:"homepage"` 31 | ConfigURL string `json:"configURL"` 32 | HookURL string `json:"hookURL"` 33 | Hooks []Hook `json:"hooks"` 34 | } 35 | 36 | // LoadPluginConfigs loads all plugin configs from the urls in PLUGIN_CONFIGS. 37 | func LoadPluginConfigs() error { 38 | client := &http.Client{ 39 | Timeout: DefaultHTTPTimeout, 40 | } 41 | 42 | plugins := make([]*Plugin, len(PluginConfigs)) 43 | for i, url := range PluginConfigs { 44 | plugin, err := LoadPluginConfig(client, url) 45 | if err != nil { 46 | return err 47 | } 48 | plugins[i] = plugin 49 | } 50 | 51 | Plugins = plugins 52 | return nil 53 | } 54 | 55 | // LoadPluginConfig loads a plugin config from a url. 56 | func LoadPluginConfig(client *http.Client, url string) (plugin *Plugin, err error) { 57 | resp, err := client.Get(url) 58 | if err != nil { 59 | return nil, err 60 | } 61 | defer func() { 62 | closeErr := resp.Body.Close() 63 | if err == nil { 64 | err = closeErr 65 | } 66 | }() 67 | 68 | err = json.NewDecoder(resp.Body).Decode(&plugin) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | if url != plugin.ConfigURL { 74 | return nil, ErrPluginConfigURLMismatch 75 | } 76 | 77 | return plugin, nil 78 | } 79 | -------------------------------------------------------------------------------- /bff/config/plugin_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func startServer(t *testing.T) *httptest.Server { 15 | chdir("") 16 | schemaPath := "../docs/plugin/schema.example.json" 17 | 18 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | file, err := os.ReadFile(schemaPath) 20 | assert.Nil(t, err) 21 | 22 | var decoded map[string]interface{} 23 | err = json.Unmarshal(file, &decoded) 24 | assert.Nil(t, err) 25 | decoded["configURL"] = fmt.Sprintf("http://%s%s", r.Host, r.URL.Path) 26 | 27 | file, err = json.Marshal(decoded) 28 | assert.Nil(t, err) 29 | 30 | _, err = w.Write(file) 31 | assert.Nil(t, err) 32 | })) 33 | 34 | return server 35 | } 36 | 37 | func TestLoadPluginConfigs(t *testing.T) { 38 | server := startServer(t) 39 | defer server.Close() 40 | 41 | originalPluginConfigs := PluginConfigs 42 | defer func() { 43 | PluginConfigs = originalPluginConfigs 44 | }() 45 | 46 | PluginConfigs = []string{server.URL + "/"} 47 | 48 | err := LoadPluginConfigs() 49 | assert.Nil(t, err) 50 | 51 | assert.Equal(t, 1, len(Plugins)) 52 | assert.Equal(t, "plugin-name", Plugins[0].Name) 53 | } 54 | -------------------------------------------------------------------------------- /bff/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/harryzcy/mailbox-browser/bff 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go-v2 v1.36.3 9 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 10 | github.com/gin-contrib/zap v1.1.5 11 | github.com/gin-gonic/gin v1.10.1 12 | github.com/spf13/viper v1.20.1 13 | github.com/stretchr/testify v1.10.0 14 | go.uber.org/zap v1.27.0 15 | go.zcy.dev/go-uid v1.3.0 16 | ) 17 | 18 | require ( 19 | github.com/aws/smithy-go v1.22.2 // indirect 20 | github.com/bytedance/sonic v1.13.2 // indirect 21 | github.com/bytedance/sonic/loader v0.2.4 // indirect 22 | github.com/cloudwego/base64x v0.1.5 // indirect 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 24 | github.com/fsnotify/fsnotify v1.8.0 // indirect 25 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 26 | github.com/gin-contrib/sse v1.0.0 // indirect 27 | github.com/go-playground/locales v0.14.1 // indirect 28 | github.com/go-playground/universal-translator v0.18.1 // indirect 29 | github.com/go-playground/validator/v10 v10.26.0 // indirect 30 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 31 | github.com/goccy/go-json v0.10.5 // indirect 32 | github.com/json-iterator/go v1.1.12 // indirect 33 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 34 | github.com/leodido/go-urn v1.4.0 // indirect 35 | github.com/mattn/go-isatty v0.0.20 // indirect 36 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 37 | github.com/modern-go/reflect2 v1.0.2 // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/sagikazarmark/locafero v0.7.0 // indirect 41 | github.com/sourcegraph/conc v0.3.0 // indirect 42 | github.com/spf13/afero v1.12.0 // indirect 43 | github.com/spf13/cast v1.7.1 // indirect 44 | github.com/spf13/pflag v1.0.6 // indirect 45 | github.com/subosito/gotenv v1.6.0 // indirect 46 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 47 | github.com/ugorji/go/codec v1.2.12 // indirect 48 | go.uber.org/multierr v1.11.0 // indirect 49 | go.zcy.dev/go-base58 v1.3.0 // indirect 50 | golang.org/x/arch v0.15.0 // indirect 51 | golang.org/x/crypto v0.36.0 // indirect 52 | golang.org/x/net v0.38.0 // indirect 53 | golang.org/x/sys v0.31.0 // indirect 54 | golang.org/x/text v0.23.0 // indirect 55 | google.golang.org/protobuf v1.36.6 // indirect 56 | gopkg.in/yaml.v3 v3.0.1 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /bff/idgen/id.go: -------------------------------------------------------------------------------- 1 | package idgen 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "go.zcy.dev/go-uid" 8 | ) 9 | 10 | var gen *uid.Generator 11 | 12 | func init() { 13 | var err error 14 | gen, err = uid.NewGenerator(0) 15 | if err != nil { 16 | fmt.Fprintf(os.Stderr, "failed to initialize id generator: %v\n", err) 17 | os.Exit(1) 18 | } 19 | 20 | } 21 | 22 | func NewID() uid.UID { 23 | return gen.Get() 24 | } 25 | -------------------------------------------------------------------------------- /bff/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "github.com/harryzcy/mailbox-browser/bff/config" 12 | "github.com/harryzcy/mailbox-browser/bff/transport/rest" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func main() { 17 | // Initialize logger 18 | logger, err := NewLogger() 19 | if err != nil { 20 | logger.Error("failed to initialize logger", 21 | zap.Error(err), 22 | ) 23 | os.Exit(1) 24 | } 25 | 26 | // Load config 27 | if err := config.Init(logger); err != nil { 28 | logger.Error("failed to initialize config", 29 | zap.Error(err), 30 | ) 31 | os.Exit(1) 32 | } 33 | 34 | // Mode is either "dev" or "prod" 35 | mode := os.Getenv("MODE") 36 | if mode == "" { 37 | mode = "dev" 38 | } 39 | 40 | // Initialization 41 | r := rest.Init(logger, mode) 42 | 43 | logger.Info("starting server...", 44 | zap.String("type", "server-status"), 45 | ) 46 | srv := &http.Server{ 47 | Addr: ":8070", 48 | Handler: r, 49 | ReadTimeout: 10 * time.Second, 50 | ReadHeaderTimeout: 10 * time.Second, 51 | WriteTimeout: 10 * time.Second, 52 | IdleTimeout: 120 * time.Second, 53 | } 54 | 55 | // Start server in a separate goroutine 56 | // and leave the main goroutine to handle the shutdown 57 | go func() { 58 | if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { 59 | logger.Fatal("ListenAndServe error", 60 | zap.String("type", "server-status"), 61 | zap.Error(err), 62 | ) 63 | } 64 | }() 65 | 66 | // Create channel to listen to OS interrupt signals 67 | quit := make(chan os.Signal, 1) 68 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 69 | 70 | // Wait for shutdown signal 71 | <-quit 72 | logger.Info("shutting down server...", 73 | zap.String("type", "server-status"), 74 | ) 75 | 76 | // The context is used to inform the server that it has 5 seconds to finish 77 | // the request it is currently handling 78 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 79 | defer cancel() 80 | if err := srv.Shutdown(ctx); err != nil { 81 | logger.Fatal("server forced to shutdown", 82 | zap.String("type", "server-status"), 83 | zap.Error(err), 84 | ) 85 | } 86 | 87 | logger.Info("server stopped", 88 | zap.String("type", "server-status"), 89 | ) 90 | } 91 | 92 | func NewLogger() (*zap.Logger, error) { 93 | cfg := zap.NewProductionConfig() 94 | paths := []string{"stderr"} 95 | if logPath := os.Getenv("LOG_PATH"); logPath != "" { 96 | paths = append(paths, logPath) 97 | } 98 | cfg.OutputPaths = paths 99 | 100 | return cfg.Build() 101 | } 102 | -------------------------------------------------------------------------------- /bff/request/aws.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/credentials" 12 | "github.com/harryzcy/mailbox-browser/bff/config" 13 | ) 14 | 15 | type Options struct { 16 | Method string 17 | Path string 18 | Query url.Values 19 | Payload []byte 20 | } 21 | 22 | // AWSRequest sends a request to AWS API Gateway and returns the response. 23 | func AWSRequest(ctx context.Context, options Options) (*http.Response, error) { 24 | endpoint := config.AWSAPIGatewayEndpoint 25 | 26 | body := bytes.NewReader(options.Payload) 27 | req, err := http.NewRequestWithContext(ctx, options.Method, endpoint+options.Path, body) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | req.URL.RawQuery = options.Query.Encode() 33 | if options.Method == http.MethodPost || options.Method == http.MethodPut { 34 | req.Header.Add("Content-Type", "application/json") 35 | } 36 | 37 | req.Header.Set("Accept", "application/json") 38 | 39 | err = signSDKRequest(ctx, req, &signSDKRequestOptions{ 40 | Credentials: credentials.StaticCredentialsProvider{ 41 | Value: aws.Credentials{ 42 | AccessKeyID: config.AWSAccessKeyID, 43 | SecretAccessKey: config.AWSSecretAccessKey, 44 | }, 45 | }, 46 | Payload: options.Payload, 47 | Region: config.AWSRegion, 48 | }) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | httpClient := &http.Client{ 54 | Timeout: 10 * time.Second, 55 | } 56 | resp, err := httpClient.Do(req) 57 | return resp, err 58 | } 59 | -------------------------------------------------------------------------------- /bff/request/sign.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/aws/aws-sdk-go-v2/aws" 12 | v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" 13 | ) 14 | 15 | var ( 16 | ErrMissingCredentials = errors.New("no credentials provided") 17 | ) 18 | 19 | type signSDKRequestOptions struct { 20 | Credentials aws.CredentialsProvider 21 | Payload []byte 22 | Region string 23 | } 24 | 25 | func signSDKRequest(ctx context.Context, req *http.Request, options *signSDKRequestOptions) error { 26 | payloadHash := hashPayload(options.Payload) 27 | if options.Credentials == nil { 28 | return ErrMissingCredentials 29 | } 30 | 31 | credentials, err := options.Credentials.Retrieve(ctx) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | signer := v4.NewSigner() 37 | err = signer.SignHTTP(ctx, 38 | credentials, req, payloadHash, "execute-api", options.Region, time.Now(), 39 | ) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | 47 | // hashPayload returns the hex-encoded SHA256 hash of the payload. 48 | func hashPayload(payload []byte) string { 49 | h := sha256.New() 50 | h.Write(payload) 51 | return fmt.Sprintf("%x", h.Sum(nil)) 52 | } 53 | -------------------------------------------------------------------------------- /bff/testdata/config.yaml: -------------------------------------------------------------------------------- 1 | aws: 2 | region: us-west-2 3 | accessKeyID: example-key-id 4 | secretAccessKey: example 5 | apiGateway: 6 | apiID: app-id 7 | endpoint: https://example.com 8 | 9 | email: 10 | addresses: example.com,example.org 11 | 12 | static: 13 | dir: /static 14 | 15 | proxy: 16 | enable: true 17 | 18 | plugin: 19 | configs: 20 | - https://example.com/plugin1 21 | - https://example.com/plugin2 22 | -------------------------------------------------------------------------------- /bff/testdata/error.yaml: -------------------------------------------------------------------------------- 1 | invalid-format 2 | -------------------------------------------------------------------------------- /bff/transport/rest/ginutil/util.go: -------------------------------------------------------------------------------- 1 | package ginutil 2 | 3 | import "github.com/gin-gonic/gin" 4 | 5 | func InternalError(c *gin.Context, err error) { 6 | c.JSON(500, gin.H{ 7 | "error": err.Error(), 8 | }) 9 | } 10 | 11 | func BadRequest(c *gin.Context, err error) { 12 | c.JSON(400, gin.H{ 13 | "error": err.Error(), 14 | }) 15 | } 16 | 17 | func Forbidden(c *gin.Context, err error) { 18 | c.JSON(403, gin.H{ 19 | "error": err.Error(), 20 | }) 21 | } 22 | 23 | func Success(c *gin.Context) { 24 | c.JSON(200, gin.H{ 25 | "status": "success", 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /bff/transport/rest/misc/config.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/harryzcy/mailbox-browser/bff/config" 6 | ) 7 | 8 | func Config(c *gin.Context) { 9 | c.JSON(200, gin.H{ 10 | "emailAddresses": config.EmailAddresses, 11 | "disableProxy": !config.ProxyEnable, 12 | "imagesAutoLoad": config.ImagesAutoLoad, 13 | "plugins": config.Plugins, 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /bff/transport/rest/misc/info.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | var ( 8 | version = "dev" 9 | commit = "n/a" 10 | buildDate = "n/a" 11 | ) 12 | 13 | func Info(c *gin.Context) { 14 | c.JSON(200, gin.H{ 15 | "version": version, 16 | "commit": commit, 17 | "build": buildDate, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /bff/transport/rest/misc/ping.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func Ping(c *gin.Context) { 8 | c.JSON(200, gin.H{ 9 | "message": "pong", 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /bff/transport/rest/misc/robots.go: -------------------------------------------------------------------------------- 1 | package misc 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | ) 6 | 7 | func Robots(c *gin.Context) { 8 | c.String(200, "User-agent: *\nDisallow: /\n") 9 | } 10 | -------------------------------------------------------------------------------- /bff/transport/rest/plugin/err.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrPluginNotFound = errors.New("plugin not found") 7 | ErrEmailGetFailed = errors.New("failed to get email") 8 | ErrInvokePlugin = errors.New("failed to invoke plugin") 9 | ) 10 | -------------------------------------------------------------------------------- /bff/transport/rest/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/harryzcy/mailbox-browser/bff/config" 11 | "github.com/harryzcy/mailbox-browser/bff/idgen" 12 | "github.com/harryzcy/mailbox-browser/bff/request" 13 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/ginutil" 14 | "github.com/harryzcy/mailbox-browser/bff/types" 15 | ) 16 | 17 | type InvokeRequest struct { 18 | Name string `json:"name"` 19 | MessageIDs []string `json:"messageIDs"` 20 | } 21 | 22 | func Invoke(c *gin.Context) { 23 | var req InvokeRequest 24 | if err := c.ShouldBindJSON(&req); err != nil { 25 | ginutil.BadRequest(c, err) 26 | return 27 | } 28 | 29 | plugin := findPluginByName(req.Name) 30 | if plugin == nil { 31 | ginutil.BadRequest(c, ErrPluginNotFound) 32 | return 33 | } 34 | 35 | emails, err := getEmails(c, req.MessageIDs) 36 | if err != nil { 37 | ginutil.InternalError(c, ErrEmailGetFailed) 38 | return 39 | } 40 | 41 | client := http.Client{ 42 | Timeout: 10 * time.Second, 43 | } 44 | err = invokePlugin(client, plugin, emails) 45 | if err != nil { 46 | ginutil.InternalError(c, ErrInvokePlugin) 47 | return 48 | } 49 | 50 | ginutil.Success(c) 51 | } 52 | 53 | // findPluginByName finds a plugin by name from the config.PLUGINS slice. 54 | // It returns nil if the plugin is not found. 55 | func findPluginByName(name string) *config.Plugin { 56 | for _, plugin := range config.Plugins { 57 | if plugin.Name == name { 58 | return plugin 59 | } 60 | } 61 | return nil 62 | } 63 | 64 | func getEmails(ctx *gin.Context, emailIDs []string) (emails []types.Email, err error) { 65 | for _, emailID := range emailIDs { 66 | resp, err := request.AWSRequest(ctx, request.Options{ 67 | Method: "GET", 68 | Path: "/emails/" + emailID, 69 | }) 70 | if err != nil { 71 | return nil, err 72 | } 73 | defer func() { 74 | closeErr := resp.Body.Close() 75 | if err == nil { 76 | err = closeErr 77 | } 78 | }() 79 | 80 | var email types.Email 81 | err = json.NewDecoder(resp.Body).Decode(&email) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | emails = append(emails, email) 87 | } 88 | 89 | return emails, nil 90 | } 91 | 92 | func invokePlugin(client http.Client, plugin *config.Plugin, emails []types.Email) error { 93 | if len(emails) == 0 { 94 | return nil 95 | } 96 | 97 | payload := types.PluginWebhookPayload{ 98 | ID: idgen.NewID(), 99 | Hook: types.HookInfo{ 100 | Name: plugin.Name, 101 | }, 102 | } 103 | 104 | if len(emails) == 1 { 105 | payload.Resources.Email = &emails[0] 106 | } else { 107 | payload.Resources.EmailList = emails 108 | 109 | } 110 | 111 | var err error 112 | body, err := json.Marshal(payload) 113 | if err != nil { 114 | return err 115 | } 116 | 117 | _, err = client.Post(plugin.HookURL, "application/json", bytes.NewReader(body)) 118 | if err != nil { 119 | return err 120 | } 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /bff/transport/rest/plugin/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/harryzcy/mailbox-browser/bff/config" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | gin.SetMode(gin.TestMode) 15 | os.Exit(m.Run()) 16 | } 17 | 18 | func TestFindPluginByName(t *testing.T) { 19 | originalPlugins := config.Plugins 20 | defer func() { 21 | config.Plugins = originalPlugins 22 | }() 23 | 24 | config.Plugins = []*config.Plugin{ 25 | { 26 | Name: "plugin1", 27 | }, 28 | { 29 | Name: "plugin2", 30 | }, 31 | } 32 | 33 | plugin := findPluginByName("plugin1") 34 | assert.Equal(t, "plugin1", plugin.Name) 35 | plugin = findPluginByName("plugin2") 36 | assert.Equal(t, "plugin2", plugin.Name) 37 | plugin = findPluginByName("plugin3") 38 | assert.Nil(t, plugin) 39 | } 40 | -------------------------------------------------------------------------------- /bff/transport/rest/proxy/mailbox.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/harryzcy/mailbox-browser/bff/request" 9 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/ginutil" 10 | ) 11 | 12 | func MailboxProxy(ctx *gin.Context) { 13 | method := ctx.Request.Method 14 | 15 | payload, err := io.ReadAll(ctx.Request.Body) 16 | if err != nil { 17 | ginutil.InternalError(ctx, err) 18 | return 19 | } 20 | 21 | resp, err := request.AWSRequest(ctx, request.Options{ 22 | Method: method, 23 | Path: strings.TrimPrefix(ctx.Request.URL.Path, "/web"), 24 | Query: ctx.Request.URL.Query(), 25 | Payload: payload, 26 | }) 27 | if err != nil { 28 | ginutil.InternalError(ctx, err) 29 | return 30 | } 31 | 32 | headers := make(map[string]string) 33 | for k, v := range resp.Header { 34 | if k == "Content-Type" { 35 | continue 36 | } 37 | headers[k] = v[0] 38 | } 39 | 40 | ctx.DataFromReader(resp.StatusCode, resp.ContentLength, resp.Header.Get("Content-Type"), resp.Body, headers) 41 | } 42 | -------------------------------------------------------------------------------- /bff/transport/rest/proxy/proxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httputil" 7 | "net/url" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/harryzcy/mailbox-browser/bff/config" 11 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/ginutil" 12 | ) 13 | 14 | func Proxy(ctx *gin.Context) { 15 | if !config.ProxyEnable { 16 | ginutil.Forbidden(ctx, errors.New("proxy disabled")) 17 | return 18 | } 19 | 20 | target, err := url.QueryUnescape(ctx.Query("l")) 21 | if err != nil { 22 | ginutil.InternalError(ctx, err) 23 | return 24 | } 25 | 26 | remote, err := url.Parse(target) 27 | if err != nil { 28 | ginutil.InternalError(ctx, err) 29 | return 30 | } 31 | proxy := httputil.NewSingleHostReverseProxy(&url.URL{ 32 | Scheme: remote.Scheme, 33 | Host: remote.Host, 34 | }) 35 | proxy.Director = func(req *http.Request) { 36 | req.Header = ctx.Request.Header 37 | req.Host = remote.Host 38 | req.URL.Scheme = remote.Scheme 39 | req.URL.Host = remote.Host 40 | req.URL.Path = remote.Path 41 | } 42 | 43 | proxy.ServeHTTP(ctx.Writer, ctx.Request) 44 | } 45 | -------------------------------------------------------------------------------- /bff/transport/rest/proxy/proxy_test.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "os" 7 | "testing" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/harryzcy/mailbox-browser/bff/config" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestProxy(t *testing.T) { 15 | original := config.ProxyEnable 16 | config.ProxyEnable = false 17 | defer func() { 18 | config.ProxyEnable = original 19 | }() 20 | 21 | w := httptest.NewRecorder() 22 | ctx, _ := gin.CreateTestContext(w) 23 | 24 | Proxy(ctx) 25 | assert.Equal(t, http.StatusForbidden, w.Code) 26 | } 27 | 28 | func TestMain(m *testing.M) { 29 | gin.SetMode(gin.TestMode) 30 | os.Exit(m.Run()) 31 | } 32 | -------------------------------------------------------------------------------- /bff/transport/rest/router.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | "time" 7 | 8 | ginzap "github.com/gin-contrib/zap" 9 | "github.com/gin-gonic/gin" 10 | "go.uber.org/zap" 11 | 12 | "github.com/harryzcy/mailbox-browser/bff/config" 13 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/misc" 14 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/plugin" 15 | "github.com/harryzcy/mailbox-browser/bff/transport/rest/proxy" 16 | ) 17 | 18 | func Init(logger *zap.Logger, mode string) *gin.Engine { 19 | logger.Info("initializing REST APIs...", 20 | zap.String("type", "server-status"), 21 | ) 22 | 23 | if mode == "prod" { 24 | gin.SetMode(gin.ReleaseMode) 25 | } 26 | 27 | r := gin.New() 28 | if mode == "prod" { 29 | r.Use(ginzap.Ginzap(logger, time.RFC3339, true), ginzap.RecoveryWithZap(logger, true)) 30 | } else { 31 | r.Use(gin.Logger(), gin.Recovery()) 32 | } 33 | 34 | webPath := r.Group("/web") 35 | webPath.Any("/*any", proxy.MailboxProxy) 36 | r.GET("/proxy", proxy.Proxy) 37 | 38 | r.POST("/plugins/invoke", plugin.Invoke) 39 | 40 | // misc routes 41 | r.GET("/ping", misc.Ping) 42 | r.GET("/info", misc.Info) 43 | r.GET("/config", misc.Config) 44 | r.GET("/robots.txt", misc.Robots) 45 | 46 | r.NoRoute(func(c *gin.Context) { 47 | if strings.HasPrefix(c.Request.URL.Path, "/assets/") { 48 | c.File(filepath.Join(config.StaticDir, c.Request.URL.Path)) 49 | } else { 50 | c.File(config.IndexHTML) 51 | } 52 | }) 53 | 54 | return r 55 | } 56 | -------------------------------------------------------------------------------- /bff/types/email.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Email struct { 4 | MessageID string `json:"messageID"` 5 | Type string `json:"type"` 6 | Subject string `json:"subject"` 7 | FromAddresses []string `json:"from"` 8 | ToAddresses []string `json:"to"` 9 | Destination []string `json:"destination"` 10 | TimeReceived string `json:"timeReceived"` 11 | DateSent string `json:"dateSent"` 12 | Source string `json:"source"` 13 | ReturnPath string `json:"returnPath"` 14 | Text string `json:"text"` 15 | HTML string `json:"html"` 16 | } 17 | -------------------------------------------------------------------------------- /bff/types/plugin.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "go.zcy.dev/go-uid" 4 | 5 | type PluginWebhookPayload struct { 6 | ID uid.UID `json:"id"` 7 | Hook HookInfo `json:"hook"` 8 | Resources HookResources `json:"resources"` 9 | } 10 | 11 | type HookInfo struct { 12 | Name string `json:"name"` 13 | } 14 | 15 | type HookResources struct { 16 | Email *Email `json:"email,omitempty"` 17 | EmailList []Email `json:"emailList,omitempty"` 18 | } 19 | -------------------------------------------------------------------------------- /cloudflare/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /cloudflare/functions/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { Env } from '../src/config' 2 | 3 | enum AuthState { 4 | Malformed = 0, 5 | NeedLogin, 6 | Authenticated 7 | } 8 | 9 | const allowedPaths = ['/info', '/ping', '/robots.txt'] 10 | 11 | export const onRequest: PagesFunction = async (context) => { 12 | const { pathname } = new URL(context.request.url) 13 | if (allowedPaths.includes(pathname)) { 14 | return await context.next() 15 | } 16 | 17 | if (context.env.AUTH_FORWARD_ADDRESS) { 18 | return await performForwardAuth(context) 19 | } 20 | 21 | if (context.env.AUTH_BASIC_USER && context.env.AUTH_BASIC_PASS) { 22 | return await performBasicAuth(context) 23 | } 24 | 25 | return await context.next() 26 | } 27 | 28 | const performForwardAuth: PagesFunction = async (context) => { 29 | const address = context.env.AUTH_FORWARD_ADDRESS 30 | 31 | const headers = context.request.headers 32 | 33 | const { protocol } = new URL(context.request.url) 34 | headers.set('X-Forwarded-Method', context.request.method) 35 | headers.set('X-Forwarded-Proto', protocol) 36 | headers.set('X-Forwarded-Host', headers.get('Host')) 37 | headers.set('X-Forwarded-URI', context.request.url) 38 | headers.set('X-Forwarded-For', headers.get('CF-Connecting-IP')) 39 | headers.set('X-Original-URL', context.request.url) 40 | 41 | console.log('headers', headers) 42 | 43 | const resp = await fetch(address, { 44 | headers, 45 | redirect: 'manual' 46 | }) 47 | if (resp.status >= 200 && resp.status < 300) { 48 | console.log('authenticated') 49 | return await context.next() 50 | } 51 | 52 | console.log('not authenticated') 53 | return new Response(resp.body, { 54 | status: resp.status, 55 | headers: resp.headers 56 | }) 57 | } 58 | 59 | const performBasicAuth: PagesFunction = async (context) => { 60 | const { protocol, pathname } = new URL(context.request.url) 61 | if ( 62 | 'https:' !== protocol || 63 | 'https' !== context.request.headers.get('x-forwarded-proto') 64 | ) { 65 | throw new Response('Please use a HTTPS connection.', { status: 400 }) 66 | } 67 | 68 | if (pathname === '/logout') { 69 | // invalidate the "Authorization" header 70 | return new Response('Logged out.', { status: 401 }) 71 | } 72 | 73 | const state = basicAuthentication(context.request, context.env) 74 | switch (state) { 75 | case AuthState.Malformed: 76 | return new Response('Malformed credentials.', { status: 400 }) 77 | case AuthState.NeedLogin: 78 | return newLoginPrompt() 79 | case AuthState.Authenticated: 80 | break 81 | default: 82 | throw new Response('Unknown authentication state.', { status: 500 }) 83 | } 84 | 85 | return await context.next() 86 | } 87 | 88 | const basicAuthentication = (request: Request, env: Env): AuthState => { 89 | const authorization = request.headers.get('Authorization') 90 | if (!authorization) { 91 | console.log('not authenticated, need login') 92 | return AuthState.NeedLogin 93 | } 94 | 95 | const [scheme, encoded] = authorization.split(' ') 96 | 97 | if (!encoded || scheme !== 'Basic') { 98 | console.log('malformed credentials') 99 | return AuthState.Malformed 100 | } 101 | 102 | const buffer = Uint8Array.from(atob(encoded), (character) => 103 | character.charCodeAt(0) 104 | ) 105 | const decoded = new TextDecoder().decode(buffer).normalize() 106 | const index = decoded.indexOf(':') 107 | if (index === -1 || /[\0-\x1F\x7F]/.test(decoded)) { 108 | return null 109 | } 110 | 111 | const user = decoded.substring(0, index) 112 | const pass = decoded.substring(index + 1) 113 | if (user !== env.AUTH_BASIC_USER || pass !== env.AUTH_BASIC_PASS) { 114 | console.log('not authenticated, need login') 115 | return AuthState.NeedLogin 116 | } 117 | console.log('authenticated') 118 | return AuthState.Authenticated 119 | } 120 | 121 | const newLoginPrompt = () => { 122 | return new Response('You need to login.', { 123 | status: 401, 124 | headers: { 125 | // Prompts the user for credentials. 126 | 'WWW-Authenticate': 'Basic realm="my scope", charset="UTF-8"' 127 | } 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /cloudflare/functions/config.ts: -------------------------------------------------------------------------------- 1 | import { Env } from '../src/config' 2 | 3 | export const onRequest: PagesFunction = async (context) => { 4 | const proxyEnable = getBooleanWithDefault(context.env, 'PROXY_ENABLE', true) 5 | const imagesAutoLoad = getBooleanWithDefault( 6 | context.env, 7 | 'IMAGES_AUTO_LOAD', 8 | true 9 | ) 10 | 11 | return new Response( 12 | JSON.stringify({ 13 | emailAddresses: context.env.EMAIL_ADDRESSES, 14 | disableProxy: !proxyEnable, 15 | imagesAutoLoad: imagesAutoLoad, 16 | plugins: [] // TODO: bring plugin support 17 | }) 18 | ) 19 | } 20 | 21 | function getBooleanWithDefault( 22 | env: Env, 23 | property: keyof Env, 24 | defaultValue: boolean 25 | ) { 26 | if (env[property] === undefined) { 27 | return defaultValue 28 | } 29 | return env[property] === 'true' 30 | } 31 | -------------------------------------------------------------------------------- /cloudflare/functions/info.ts: -------------------------------------------------------------------------------- 1 | import { BUILD_VERSION, BUILD_COMMIT, BUILD_DATE } from '../src/buildInfo' 2 | 3 | export const onRequest: PagesFunction = async () => { 4 | return new Response( 5 | JSON.stringify({ 6 | version: BUILD_VERSION, 7 | commit: BUILD_COMMIT, 8 | build: BUILD_DATE 9 | }) 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /cloudflare/functions/ping.ts: -------------------------------------------------------------------------------- 1 | export const onRequest: PagesFunction = async () => { 2 | return new Response( 3 | JSON.stringify({ 4 | message: 'pong' 5 | }) 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /cloudflare/functions/plugins/invoke.ts: -------------------------------------------------------------------------------- 1 | import { AwsClient } from 'aws4fetch' 2 | import { Env, Plugin } from '../../src/config' 3 | import { parsePlugins } from '../../src/plugin' 4 | import { Email } from '../../src/email' 5 | 6 | interface InvokeRequest { 7 | Name: string 8 | MessageIDs: string[] 9 | } 10 | 11 | export const onRequest: PagesFunction = async (context) => { 12 | const plugins = parsePlugins(context.env.PLUGINS) 13 | const req = await context.request.json() 14 | 15 | const aws = new AwsClient({ 16 | accessKeyId: context.env.AWS_ACCESS_KEY_ID, 17 | secretAccessKey: context.env.AWS_SECRET_ACCESS_KEY, 18 | service: 'execute-api', 19 | region: context.env.AWS_REGION 20 | }) 21 | let endpoint = context.env.AWS_API_GATEWAY_ENDPOINT 22 | 23 | const plugin = plugins.find((plugin) => plugin.Name === req.Name) 24 | 25 | const emails = await getEmails(aws, endpoint, req.MessageIDs) 26 | return await invokePlugin(plugin, emails) 27 | } 28 | 29 | const getEmails = async ( 30 | aws: AwsClient, 31 | endpoint: string, 32 | emailIDs: string[] 33 | ): Promise => { 34 | const emails: Email[] = [] 35 | for (const emailID of emailIDs) { 36 | const res = await aws.fetch(`${endpoint}/emails/${emailID}`, { 37 | method: 'GET' 38 | }) 39 | emails.push(await res.json()) 40 | } 41 | return emails 42 | } 43 | 44 | const invokePlugin = async (plugin: Plugin, emails: Email[]) => { 45 | if (emails.length === 0) { 46 | return 47 | } 48 | 49 | let url: string 50 | let body: string 51 | if (emails.length === 1) { 52 | url = plugin.Endpoints.Email 53 | body = JSON.stringify(emails[0]) 54 | } else { 55 | url = plugin.Endpoints.Emails 56 | if (url === '') { 57 | throw new Error('batch email endpoint not found') 58 | } 59 | body = JSON.stringify(emails) 60 | } 61 | 62 | return fetch(url, { 63 | body, 64 | headers: { 65 | 'Content-Type': 'application/json' 66 | } 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /cloudflare/functions/proxy.ts: -------------------------------------------------------------------------------- 1 | import { Env } from '../src/config' 2 | 3 | export const onRequest: PagesFunction = async (context) => { 4 | if (!context.env.PROXY_ENABLE) { 5 | return new Response( 6 | JSON.stringify({ 7 | reason: 'proxy disabled' 8 | }), 9 | { status: 403 } 10 | ) 11 | } 12 | 13 | const url = context.request.url 14 | const { searchParams } = new URL(url) 15 | const target = searchParams.get('l') 16 | if (!target) { 17 | return new Response( 18 | JSON.stringify({ 19 | reason: 'missing target URL' 20 | }), 21 | { status: 400 } 22 | ) 23 | } 24 | 25 | const res = await fetch(target) 26 | 27 | const newResponse = new Response(res.body, res) 28 | newResponse.headers.delete('Set-Cookie') 29 | return newResponse 30 | } 31 | -------------------------------------------------------------------------------- /cloudflare/functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": ["esnext"], 6 | "moduleResolution": "node16", 7 | "types": ["@cloudflare/workers-types"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cloudflare/functions/web/[[catchall]].ts: -------------------------------------------------------------------------------- 1 | import { AwsClient } from 'aws4fetch' 2 | import { Env } from '../../src/config' 3 | 4 | export const onRequest: PagesFunction = async (context) => { 5 | const segments = context.params.catchall as string[] 6 | 7 | const aws = new AwsClient({ 8 | accessKeyId: context.env.AWS_ACCESS_KEY_ID, 9 | secretAccessKey: context.env.AWS_SECRET_ACCESS_KEY, 10 | service: 'execute-api', 11 | region: context.env.AWS_REGION 12 | }) 13 | 14 | let endpoint = context.env.AWS_API_GATEWAY_ENDPOINT 15 | if (endpoint.endsWith('/')) { 16 | endpoint = endpoint.slice(0, -1) 17 | } 18 | const path = segments.join('/') 19 | const url = context.request.url 20 | const query = url.includes('?') ? `?${url.split('?')[1]}` : '' 21 | 22 | const data = { 23 | method: context.request.method 24 | } as RequestInit 25 | if (context.request.headers.get('Content-Type') === 'application/json') { 26 | data.body = context.request.body 27 | } 28 | 29 | const res = await aws.fetch(`${endpoint}/${path}${query}`, data) 30 | return res 31 | } 32 | -------------------------------------------------------------------------------- /cloudflare/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@cloudflare/workers-types": "4.20250604.0", 4 | "typescript": "5.8.3", 5 | "wrangler": "4.19.1" 6 | }, 7 | "dependencies": { 8 | "aws4fetch": "1.0.20" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /cloudflare/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /cloudflare/src/config.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | AUTH_BASIC_USER: string 3 | AUTH_BASIC_PASS: string 4 | AUTH_FORWARD_ADDRESS: string 5 | 6 | AWS_REGION: string 7 | AWS_ACCESS_KEY_ID: string 8 | AWS_SECRET_ACCESS_KEY: string 9 | AWS_API_GATEWAY_ENDPOINT: string 10 | 11 | EMAIL_ADDRESSES: string[] 12 | PROXY_ENABLE: string 13 | IMAGES_AUTO_LOAD: string 14 | 15 | PLUGINS: string 16 | } 17 | 18 | export interface Plugin { 19 | Name: string 20 | DisplayName: string 21 | Endpoints: Endpoint 22 | } 23 | 24 | export interface Endpoint { 25 | Email: string 26 | Emails: string 27 | } 28 | -------------------------------------------------------------------------------- /cloudflare/src/email.ts: -------------------------------------------------------------------------------- 1 | export interface Email { 2 | messageID: string 3 | type: string 4 | subject: string 5 | fromAddresses: string[] 6 | toAddresses: string[] 7 | destination: string[] 8 | timeReceived: string 9 | dateSent: string 10 | source: string 11 | returnPath: string 12 | text: string 13 | html: string 14 | } 15 | -------------------------------------------------------------------------------- /cloudflare/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from './config' 2 | 3 | export const parsePlugins = (plugins: string): Plugin[] => { 4 | return JSON.parse(plugins) 5 | } 6 | -------------------------------------------------------------------------------- /docs/plugin/schema.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "v0", 3 | "name": "plugin-name", 4 | "displayName": "Plugin Name", 5 | "description": "A description for the plugin", 6 | "author": "Author Name", 7 | "homepage": "https://example.com", 8 | "configURL": "https://example.com/config", 9 | "hookURL": "https://example.com/hook", 10 | "hooks": [ 11 | { 12 | "type": "action", 13 | "name": "action-name-to-identify-hook", 14 | "displayName": "Action Name", 15 | "resources": [ 16 | "email", 17 | "emailList" 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /docs/state.md: -------------------------------------------------------------------------------- 1 | # State Management 2 | 3 | States shared across components are managed through React Contexts. There are currently two groups of states: global configurations and draft emails. 4 | 5 | ## Configuration 6 | 7 | Configuration is handled via `ConfigContext`. The config data is loaded in `EmailRoot` once during initial loading using the `set` action, and then it's only read in other components. 8 | 9 | ## Draft Emails 10 | 11 | `DraftEmailContext` manages the state of draft emails. It acts as a working directory for draft emails, while the backend database persists the data. Any changes are stored in working directory and then persisted by sending requests at specific intervals or at certain events. 12 | 13 | The state consists of the following: 14 | 15 | - `activeEmail`: the active draft email that's rendered in full-screen mode. 16 | - `emails`: The full list of emails that's opened and displayed in tabs. 17 | 18 | The following actions manages the state: 19 | 20 | - `new`: Adds a new draft email (not a reply or forward) to the working directory. This is dispatched when user clicks **New** button. 21 | - `new-reply`: Adds a new draft reply to the working directory. 22 | - `new-forward`: Adds a new draft forward to the working directory. 23 | - `open`: Opens a draft email in Draft tab. 24 | - `load`: Loads an email to the working directory. This should be done by making a HTTP request and using the response content. If the email is already in the working directory, the email should be opened instead (identical to `open`). 25 | - `minimize`: Convert full-page draft to minimized state 26 | - `remove`: Remove an email from the working directory. 27 | - `update`: Update a draft email when any of subject, content, recipient, etc is changed, optionally append to the update waitlist. 28 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "importOrder": [ 7 | "^@ui", 8 | "^components", 9 | "^context", 10 | "^hooks", 11 | "^lib", 12 | "^pages", 13 | "^services", 14 | "^utils", 15 | "^[./]" 16 | ], 17 | "importOrderSeparation": true, 18 | "importOrderSortSpecifiers": true, 19 | "plugins": [ 20 | "prettier-plugin-tailwindcss", 21 | "@trivago/prettier-plugin-sort-imports" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/components/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/components/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /web/eslint.config.ts: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import reactPlugin from 'eslint-plugin-react' 3 | import tsEslint from 'typescript-eslint' 4 | 5 | export default tsEslint.config( 6 | { 7 | ignores: [ 8 | 'dist/', 9 | 'coverage/', 10 | 'node_modules/', 11 | '.wrangler/', 12 | '*.config.js', 13 | '*.config.ts', 14 | '*.config.cjs' 15 | ] 16 | }, 17 | eslint.configs.recommended, 18 | ...tsEslint.configs.strictTypeChecked, 19 | ...tsEslint.configs.stylisticTypeChecked, 20 | reactPlugin.configs.flat.recommended, 21 | reactPlugin.configs.flat['jsx-runtime'], 22 | { 23 | languageOptions: { 24 | parserOptions: { 25 | project: true, 26 | tsconfigRootDir: import.meta.dirname, 27 | ecmaVersion: 2024 28 | } 29 | }, 30 | rules: { 31 | semi: [2, 'never'] 32 | }, 33 | settings: { 34 | react: { 35 | version: 'detect' 36 | } 37 | } 38 | }, 39 | { 40 | files: ['**/*.js'], 41 | ...tsEslint.configs.disableTypeChecked 42 | } 43 | ) 44 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mailbox Browser 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /web/jest.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | moduleDirectories: ['node_modules', 'src'] 5 | } 6 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint", 11 | "prettier": "prettier --config .prettierrc 'src/**/*.{ts,tsx}' --write", 12 | "test": "jest --coverage" 13 | }, 14 | "dependencies": { 15 | "@adobe/css-tools": "4.4.3", 16 | "@heroicons/react": "2.2.0", 17 | "@lexical/clipboard": "0.32.1", 18 | "@lexical/code": "0.32.1", 19 | "@lexical/html": "0.32.1", 20 | "@lexical/link": "0.32.1", 21 | "@lexical/list": "0.32.1", 22 | "@lexical/markdown": "0.32.1", 23 | "@lexical/react": "0.32.1", 24 | "@lexical/rich-text": "0.32.1", 25 | "@lexical/selection": "0.32.1", 26 | "@lexical/table": "0.32.1", 27 | "@lexical/utils": "0.32.1", 28 | "@radix-ui/react-toast": "1.2.14", 29 | "@tailwindcss/vite": "4.1.8", 30 | "class-variance-authority": "0.7.1", 31 | "clsx": "2.1.1", 32 | "dompurify": "3.2.6", 33 | "html-react-parser": "5.2.5", 34 | "lexical": "0.32.1", 35 | "lucide-react": "0.513.0", 36 | "next-themes": "0.4.6", 37 | "react": "19.1.0", 38 | "react-dom": "19.1.0", 39 | "react-error-boundary": "6.0.0", 40 | "react-router": "7.6.2", 41 | "sonner": "2.0.5", 42 | "tailwind-merge": "3.3.0", 43 | "tailwindcss": "4.1.8", 44 | "tailwindcss-animate": "1.0.7", 45 | "typescript-eslint": "8.33.1", 46 | "yjs": "13.6.27" 47 | }, 48 | "devDependencies": { 49 | "@testing-library/jest-dom": "6.6.3", 50 | "@testing-library/react": "16.3.0", 51 | "@trivago/prettier-plugin-sort-imports": "5.2.2", 52 | "@types/jest": "29.5.14", 53 | "@types/react": "19.1.6", 54 | "@types/react-dom": "19.1.6", 55 | "@typescript-eslint/eslint-plugin": "8.33.1", 56 | "@typescript-eslint/parser": "8.33.1", 57 | "@vitejs/plugin-react": "4.5.1", 58 | "eslint": "9.28.0", 59 | "eslint-config-prettier": "10.1.5", 60 | "eslint-plugin-react": "7.37.5", 61 | "jest": "29.7.0", 62 | "jest-environment-jsdom": "29.7.0", 63 | "jiti": "2.4.2", 64 | "prettier": "3.5.3", 65 | "prettier-plugin-tailwindcss": "0.6.12", 66 | "ts-jest": "29.3.4", 67 | "ts-node": "10.9.2", 68 | "typescript": "5.8.3", 69 | "vite": "6.3.5", 70 | "vite-tsconfig-paths": "5.1.4" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | RouterProvider, 3 | createBrowserRouter, 4 | redirect, 5 | useRouteError 6 | } from 'react-router' 7 | 8 | import EmailList from 'pages/EmailList' 9 | import EmailRawView from 'pages/EmailRawView' 10 | import EmailRoot from 'pages/EmailRoot' 11 | import EmailView from 'pages/EmailView' 12 | import Root from 'pages/Root' 13 | 14 | import { getEmail, getEmailRaw } from 'services/emails' 15 | import { getThread } from 'services/threads' 16 | 17 | const router = createBrowserRouter([ 18 | { 19 | path: '/', 20 | element: , 21 | errorElement: , 22 | children: [ 23 | { 24 | path: '', 25 | loader: () => redirect('inbox') 26 | }, 27 | { 28 | path: 'inbox', 29 | element: , 30 | errorElement: , 31 | children: [ 32 | { 33 | path: '', 34 | element: 35 | }, 36 | { 37 | path: 'thread/:threadID', 38 | element: , 39 | errorElement: , 40 | loader: ({ params }) => { 41 | if (!params.threadID) return redirect('/inbox') 42 | return { 43 | type: 'thread', 44 | threadID: params.threadID, 45 | thread: getThread(params.threadID) 46 | } 47 | } 48 | }, 49 | { 50 | path: ':messageID', 51 | element: , 52 | errorElement: , 53 | loader: ({ params }) => { 54 | if (!params.messageID) return redirect('/inbox') 55 | return { 56 | type: 'email', 57 | messageID: params.messageID, 58 | email: getEmail(params.messageID) 59 | } 60 | } 61 | } 62 | ] 63 | }, 64 | { 65 | path: 'drafts', 66 | element: , 67 | children: [ 68 | { 69 | path: '', 70 | element: , 71 | errorElement: 72 | }, 73 | { 74 | path: ':messageID', 75 | element: , 76 | loader: ({ params }) => { 77 | if (!params.messageID) return null 78 | return { 79 | messageID: params.messageID, 80 | email: getEmail(params.messageID) 81 | } 82 | } 83 | } 84 | ] 85 | }, 86 | { 87 | path: 'sent', 88 | element: , 89 | errorElement: , 90 | children: [ 91 | { 92 | path: '', 93 | element: 94 | }, 95 | { 96 | path: ':messageID', 97 | element: , 98 | loader: ({ params }) => { 99 | if (!params.messageID) return redirect('/sent') 100 | return { 101 | messageID: params.messageID, 102 | email: getEmail(params.messageID) 103 | } 104 | } 105 | } 106 | ] 107 | } 108 | ] 109 | }, 110 | { 111 | path: '/raw/:messageID', 112 | element: , 113 | errorElement: , 114 | loader: ({ params }) => { 115 | if (!params.messageID) return redirect('/inbox') 116 | return { 117 | messageID: params.messageID, 118 | raw: getEmailRaw(params.messageID) 119 | } 120 | } 121 | } 122 | ]) 123 | 124 | function ErrorBoundary() { 125 | const error = useRouteError() as { 126 | status: number 127 | error: unknown 128 | data: string 129 | internal: boolean 130 | statusText: string 131 | } 132 | console.error(error) 133 | console.error(error.error) 134 | // Uncaught ReferenceError: path is not defined 135 | return
Unknown Error: {error.data}
136 | } 137 | 138 | function App() { 139 | return ( 140 |
141 | 142 |
143 | ) 144 | } 145 | 146 | export default App 147 | -------------------------------------------------------------------------------- /web/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentTextIcon, 3 | InboxIcon, 4 | PaperAirplaneIcon 5 | } from '@heroicons/react/24/outline' 6 | import { ReactElement, forwardRef, useEffect, useState } from 'react' 7 | import { NavLink } from 'react-router' 8 | 9 | import { getInfo } from 'services/info' 10 | 11 | import { browserVersion } from 'utils/info' 12 | 13 | const Sidebar = forwardRef(function Sidebar(props, ref) { 14 | const navItems: [string, string, ReactElement][] = [ 15 | ['Inbox', '/inbox', ], 16 | ['Drafts', '/drafts', ], 17 | ['Sent', '/sent', ] 18 | ] 19 | 20 | const [mailboxVersion, setMailboxVersion] = useState('') 21 | useEffect(() => { 22 | void getInfo().then((info) => { 23 | setMailboxVersion(info.version) 24 | }) 25 | }, []) 26 | 27 | return ( 28 | 67 | ) 68 | }) 69 | 70 | export default Sidebar 71 | -------------------------------------------------------------------------------- /web/src/components/emails/DraftEmailsTabs.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import { DraftEmailsContext } from 'contexts/DraftEmailContext' 4 | 5 | export default function DraftEmailsTabs() { 6 | const draftEmailsContext = useContext(DraftEmailsContext) 7 | 8 | if (draftEmailsContext.emails.length === 0) { 9 | return null 10 | } 11 | 12 | return ( 13 |
21 |
22 | {draftEmailsContext.emails.map((email) => { 23 | return ( 24 |
{ 28 | draftEmailsContext.dispatch({ 29 | type: 'open', 30 | messageID: email.messageID 31 | }) 32 | }} 33 | > 34 | {email.subject || 'New Email'} 35 |
36 | ) 37 | })} 38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /web/src/components/emails/EmailDraft.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * EmailDraft.tsx 3 | * This component will occupy full screen when a user is composing an email. 4 | */ 5 | import { MinusIcon, XMarkIcon } from '@heroicons/react/20/solid' 6 | 7 | import EmailAddressInput from 'components/inputs/EmailAddressInput' 8 | import RichTextEditor from 'components/inputs/RichTextEditor' 9 | import TextInput from 'components/inputs/TextInput' 10 | 11 | import { DraftEmail } from 'contexts/DraftEmailContext' 12 | 13 | interface EmailDraftProps { 14 | email: DraftEmail 15 | handleEmailChange: (email: DraftEmail) => void 16 | handleClose?: () => void 17 | handleMinimize?: () => void 18 | handleSend: () => void 19 | handleDelete?: () => void 20 | isReply?: boolean 21 | } 22 | 23 | export function EmailDraft(props: EmailDraftProps) { 24 | const { 25 | email, 26 | handleEmailChange, 27 | handleClose, 28 | handleMinimize, 29 | handleSend, 30 | handleDelete, 31 | isReply = false 32 | } = props 33 | 34 | return ( 35 |
40 | {!isReply && ( 41 |
42 | {email.subject || 'New Email'} 43 | 44 | 48 | 49 | 50 | 54 | 55 | 56 | 57 |
58 | )} 59 | 60 | {!isReply && ( 61 |
62 | 63 | From 64 | 65 | 66 | { 70 | handleEmailChange({ ...email, from: emails }) 71 | }} 72 | /> 73 | 74 |
75 | )} 76 |
77 | 78 | To 79 | 80 | 81 | { 85 | handleEmailChange({ ...email, to: emails }) 86 | }} 87 | /> 88 | 89 |
90 |
91 | 92 | Cc 93 | 94 | 95 | { 99 | handleEmailChange({ ...email, cc: emails }) 100 | }} 101 | /> 102 | 103 |
104 |
105 | 106 | Bcc 107 | 108 | 109 | { 113 | handleEmailChange({ ...email, bcc: emails }) 114 | }} 115 | /> 116 | 117 |
118 | 119 | { 120 | // Reply emails should used standard subject 121 | !isReply && ( 122 |
123 | 124 | { 128 | handleEmailChange({ ...email, subject }) 129 | }} 130 | /> 131 | 132 |
133 | ) 134 | } 135 | 136 |
137 | { 140 | handleEmailChange({ ...email, html, text }) 141 | }} 142 | handleSend={handleSend} 143 | handleDelete={() => { 144 | if (handleDelete) handleDelete() 145 | }} 146 | /> 147 |
148 |
149 | ) 150 | } 151 | -------------------------------------------------------------------------------- /web/src/components/emails/EmailMenuBar.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' 2 | import { 3 | EllipsisVerticalIcon, 4 | EnvelopeIcon, 5 | EnvelopeOpenIcon, 6 | TrashIcon 7 | } from '@heroicons/react/24/outline' 8 | import { useContext, useEffect, useState } from 'react' 9 | 10 | import { ConfigContext, Plugin } from 'contexts/ConfigContext' 11 | import { DraftEmailsContext } from 'contexts/DraftEmailContext' 12 | 13 | import { createEmail, generateLocalDraftID } from 'services/emails' 14 | import * as plugins from 'services/plugins' 15 | 16 | interface EmailMenuBarProps { 17 | emailIDs: string[] 18 | handleBack: () => void 19 | showOperations: boolean 20 | handleDelete: () => void 21 | handleRead: () => void 22 | handleUnread: () => void 23 | hasPrevious: boolean 24 | hasNext: boolean 25 | goPrevious: () => void 26 | goNext: () => void 27 | children?: React.ReactNode 28 | } 29 | 30 | export default function EmailMenuBar(props: EmailMenuBarProps) { 31 | const { 32 | emailIDs, 33 | handleBack, 34 | showOperations, 35 | handleDelete, 36 | handleRead, 37 | handleUnread, 38 | hasPrevious, 39 | hasNext, 40 | goPrevious, 41 | goNext, 42 | children 43 | } = props 44 | 45 | return ( 46 | <> 47 |
48 |
49 | 50 | 57 |
58 | 59 | 65 | {children} 66 | 67 |
68 | 69 |
70 | {showOperations ? ( 71 | <> 72 | 76 | 77 | 78 |
79 | 86 |
87 | 88 | ) : ( 89 | <> 90 | 91 | 97 | {children} 98 | 99 | 100 | )} 101 |
102 | 103 | ) 104 | } 105 | 106 | function ComposeButton() { 107 | const { dispatch: dispatchDraftEmail } = useContext(DraftEmailsContext) 108 | 109 | const handleCreate = async () => { 110 | const draftID = generateLocalDraftID() 111 | 112 | dispatchDraftEmail({ 113 | type: 'new', 114 | messageID: draftID 115 | }) 116 | 117 | const body = { 118 | subject: '', 119 | from: [], 120 | to: [], 121 | cc: [], 122 | bcc: [], 123 | replyTo: [], 124 | html: '', 125 | text: '', 126 | send: false 127 | } 128 | 129 | const email = await createEmail(body) 130 | 131 | dispatchDraftEmail({ 132 | type: 'update', 133 | messageID: draftID, 134 | email 135 | }) 136 | } 137 | 138 | return ( 139 | 144 | 145 | 146 | 147 | Compose 148 | 149 | ) 150 | } 151 | 152 | function ActionBar(props: { 153 | emailIDs: string[] 154 | handleDelete: () => void 155 | handleRead: () => void 156 | handleUnread: () => void 157 | showOperations: boolean 158 | }) { 159 | const { emailIDs, handleDelete, handleRead, handleUnread, showOperations } = 160 | props 161 | const configContext = useContext(ConfigContext) 162 | const [showPluginMenu, setShowPluginMenu] = useState(false) 163 | useEffect(() => { 164 | setShowPluginMenu(false) 165 | }, [showOperations]) 166 | 167 | const invokePlugin = (plugin: Plugin) => { 168 | setShowPluginMenu(false) 169 | void plugins.invoke(plugin.name, emailIDs) 170 | } 171 | 172 | if (!showOperations) { 173 | return null 174 | } 175 | 176 | return ( 177 | <> 178 | 182 | 183 | 184 | 188 | 189 | 190 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | { 203 | setShowPluginMenu(!showPluginMenu) 204 | }} 205 | > 206 | 207 | 208 | {showPluginMenu && ( 209 |
210 | {configContext.state.config.plugins.length === 0 ? ( 211 |
No plugins installed
212 | ) : ( 213 | configContext.state.config.plugins.map((plugin) => ( 214 |
{ 218 | invokePlugin(plugin) 219 | }} 220 | > 221 | {plugin.displayName} 222 |
223 | )) 224 | )} 225 |
226 | )} 227 |
228 | 229 | ) 230 | } 231 | 232 | function YearMonthNavigation(props: { 233 | hasPrevious: boolean 234 | hasNext: boolean 235 | goPrevious: () => void 236 | goNext: () => void 237 | children?: React.ReactNode 238 | }) { 239 | const { hasPrevious, hasNext, goPrevious, goNext, children } = props 240 | return ( 241 | 277 | ) 278 | } 279 | -------------------------------------------------------------------------------- /web/src/components/emails/EmailName.tsx: -------------------------------------------------------------------------------- 1 | import { parseEmailName } from 'utils/emails' 2 | 3 | export default function EmailName({ 4 | emails, 5 | showAddress = false 6 | }: { 7 | emails: string[] | null 8 | showAddress?: boolean 9 | }) { 10 | const parsed = parseEmailName(emails) 11 | if (parsed.name === null && parsed.address === null) return null 12 | 13 | if (parsed.name === null) return parsed.address ?? '' 14 | if (parsed.address === null) return parsed.name 15 | 16 | return ( 17 | <> 18 | {parsed.name} 19 | {showAddress && ( 20 | 21 | {' '} 22 | <{parsed.address}> 23 | 24 | )} 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /web/src/components/emails/EmailTableRow.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon } from '@heroicons/react/20/solid' 2 | import { useContext } from 'react' 3 | import { useNavigate } from 'react-router' 4 | 5 | import EmailName from 'components/emails/EmailName' 6 | 7 | import { DraftEmailsContext } from 'contexts/DraftEmailContext' 8 | 9 | import { EmailInfo, getEmail } from 'services/emails' 10 | 11 | import { formatDate } from 'utils/time' 12 | 13 | interface EmailTableRowProps { 14 | email: EmailInfo 15 | selected: boolean 16 | toggleSelect: () => void 17 | } 18 | 19 | export default function EmailTableRow(props: EmailTableRowProps) { 20 | const { email, toggleSelect, selected } = props 21 | const backgroundClassName = selected ? ' bg-blue-100 dark:bg-neutral-700' : '' 22 | const unreadClassName = email.unread ? ' font-bold' : ' dark:font-light' 23 | 24 | const draftEmailsContext = useContext(DraftEmailsContext) 25 | 26 | const navigate = useNavigate() 27 | 28 | const openDraftEmail = async (messageID: string) => { 29 | const emailDetail = await getEmail(messageID) 30 | draftEmailsContext.dispatch({ 31 | type: 'load', 32 | email: emailDetail 33 | }) 34 | } 35 | 36 | const openEmail = async () => { 37 | if (email.type === 'draft') { 38 | if (email.threadID) { 39 | await navigate(`/inbox/thread/${email.threadID}`) 40 | return 41 | } 42 | void openDraftEmail(email.messageID) 43 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 44 | } else if (email.type === 'inbox' || email.type === 'sent') { 45 | if (email.threadID) { 46 | await navigate(`/inbox/thread/${email.threadID}`) 47 | return 48 | } 49 | await navigate(`/inbox/${email.messageID}`) 50 | } 51 | } 52 | 53 | return ( 54 |
55 |
63 | 64 |
72 | {selected && } 73 |
74 |
75 |
76 |
{ 83 | void openEmail() 84 | }} 85 | > 86 | 0 ? email.from[0] : ''}> 87 | 88 | 89 |
90 |
{ 97 | void openEmail() 98 | }} 99 | > 100 | {email.subject} 101 |
102 |
{ 109 | void openEmail() 110 | }} 111 | > 112 | {formatDate( 113 | email.timeReceived ?? email.timeUpdated ?? email.timeSent ?? '', 114 | { short: true } 115 | )} 116 |
117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /web/src/components/emails/EmailTableView.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import useIsInViewport from 'hooks/useIsInViewport' 4 | 5 | import { EmailInfo } from 'services/emails' 6 | 7 | import EmailTableRow from './EmailTableRow' 8 | 9 | interface EmailTableViewProps { 10 | emails: EmailInfo[] 11 | selected: string[] 12 | toggleSelected: (messageID: string) => void 13 | hasMore: boolean 14 | loadMoreEmails: () => void 15 | } 16 | 17 | export default function EmailTableView(props: EmailTableViewProps) { 18 | const { emails = [], toggleSelected, loadMoreEmails } = props 19 | 20 | const loadMoreRef = useRef(null) 21 | const shouldLoadMore = useIsInViewport(loadMoreRef) 22 | useEffect(() => { 23 | if (shouldLoadMore) { 24 | loadMoreEmails() 25 | } 26 | }, [shouldLoadMore]) 27 | 28 | return ( 29 |
30 | {emails.map((email) => { 31 | return ( 32 | { 37 | toggleSelected(email.messageID) 38 | }} 39 | /> 40 | ) 41 | })} 42 | 43 |
50 | {props.hasMore ? 'Loading...' : 'No more emails'} 51 |
52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /web/src/components/emails/FullScreenContent.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from 'react' 2 | 3 | import { DraftEmail, DraftEmailsContext } from 'contexts/DraftEmailContext' 4 | 5 | import useThrottled from 'hooks/useThrottled' 6 | 7 | import { deleteEmail, saveEmail } from 'services/emails' 8 | 9 | import { EmailDraft } from './EmailDraft' 10 | 11 | interface FullScreenContentProps { 12 | handleDelete: (messageID: string) => void 13 | } 14 | 15 | export default function FullScreenContent(props: FullScreenContentProps) { 16 | const draftEmailsContext = useContext(DraftEmailsContext) 17 | 18 | const [isSending, setIsSending] = useState(false) 19 | const [draftEmail, setDraftEmail] = useState() 20 | const throttledDraftEmail = useThrottled(draftEmail) 21 | 22 | const saveDraft = async (email: DraftEmail, send = false) => { 23 | await saveEmail({ 24 | messageID: email.messageID, 25 | subject: email.subject, 26 | from: email.from, 27 | to: email.to, 28 | cc: email.cc, 29 | bcc: email.bcc, 30 | replyTo: email.from, 31 | html: email.html, 32 | text: email.text, 33 | send 34 | }) 35 | } 36 | 37 | useEffect(() => { 38 | const save = async () => { 39 | if (isSending) return 40 | if (!draftEmail) return 41 | await saveDraft(draftEmail) 42 | } 43 | 44 | void save() 45 | }, [throttledDraftEmail]) 46 | 47 | const handleEmailChange = (email: DraftEmail) => { 48 | setDraftEmail(email) 49 | 50 | draftEmailsContext.dispatch({ 51 | type: 'update', 52 | messageID: email.messageID, 53 | email 54 | }) 55 | } 56 | 57 | const handleClose = () => { 58 | if (!draftEmailsContext.activeEmail) return 59 | 60 | draftEmailsContext.dispatch({ 61 | type: 'remove', 62 | messageID: draftEmailsContext.activeEmail.messageID 63 | }) 64 | } 65 | 66 | const handleMinimize = () => { 67 | draftEmailsContext.dispatch({ 68 | type: 'minimize' 69 | }) 70 | } 71 | 72 | const handleSend = async () => { 73 | const email = draftEmailsContext.activeEmail 74 | if (!email) return 75 | 76 | setIsSending(true) // prevent saving draft 77 | const shouldSend = true // save and send 78 | await saveDraft(email, shouldSend) 79 | 80 | draftEmailsContext.dispatch({ 81 | type: 'remove', 82 | messageID: email.messageID 83 | }) 84 | } 85 | 86 | const handleDelete = () => { 87 | const deleteRequest = async () => { 88 | const email = draftEmailsContext.activeEmail 89 | if (!email) return 90 | await deleteEmail(email.messageID) 91 | 92 | draftEmailsContext.dispatch({ 93 | type: 'remove', 94 | messageID: email.messageID 95 | }) 96 | props.handleDelete(email.messageID) 97 | } 98 | 99 | void deleteRequest() 100 | } 101 | 102 | if ( 103 | draftEmailsContext.activeEmail === null || 104 | draftEmailsContext.activeEmail.replyEmail || 105 | draftEmailsContext.activeEmail.threadID 106 | ) { 107 | return null 108 | } 109 | 110 | return ( 111 |
112 | { 118 | void handleSend() 119 | }} 120 | handleDelete={handleDelete} 121 | /> 122 |
123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /web/src/components/inputs/EmailAddressInput.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from '@heroicons/react/20/solid' 2 | import { useState } from 'react' 3 | 4 | export interface EmailAddressInputProps { 5 | addresses: string[] 6 | placeholder?: string 7 | handleChange: (emails: string[]) => void 8 | } 9 | 10 | export default function EmailAddressInput(props: EmailAddressInputProps) { 11 | const { addresses, placeholder, handleChange } = props 12 | 13 | const [stash, setStash] = useState('') 14 | 15 | const removeEmail = (email: string) => { 16 | const emails = addresses.filter((address) => address !== email) 17 | handleChange(emails) 18 | } 19 | 20 | const onBlur = (e: React.ChangeEvent) => { 21 | const email = e.target.value 22 | if (validateEmail(email)) { 23 | if (addresses.includes(email)) { 24 | return 25 | } 26 | const emails = [...addresses, email] 27 | setStash('') 28 | handleChange(emails) 29 | } 30 | } 31 | 32 | const validateEmail = (email: string) => { 33 | const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ 34 | return re.test(email) 35 | } 36 | 37 | return ( 38 | 39 | {addresses.map((address) => { 40 | return ( 41 | 45 | {address} 46 | { 49 | removeEmail(address) 50 | }} 51 | > 52 | 53 | 54 | 55 | ) 56 | })} 57 | 58 | { 64 | setStash(e.target.value) 65 | }} 66 | onBlur={onBlur} 67 | > 68 | 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /web/src/components/inputs/EmailQuoteNode.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type DOMConversionMap, 3 | type DOMConversionOutput, 4 | type DOMExportOutput, 5 | DecoratorNode, 6 | type EditorConfig, 7 | LexicalEditor, 8 | LexicalNode, 9 | type NodeKey, 10 | type SerializedLexicalNode 11 | } from 'lexical' 12 | import { ReactNode } from 'react' 13 | 14 | import { parseEmailHTML } from 'utils/emails' 15 | 16 | export class EmailQuoteNode extends DecoratorNode { 17 | __html: string 18 | 19 | constructor(html: string, key?: NodeKey) { 20 | super(key) 21 | this.__html = html 22 | } 23 | 24 | static getType(): string { 25 | return 'emailquote' 26 | } 27 | 28 | static clone(node: EmailQuoteNode): EmailQuoteNode { 29 | return new EmailQuoteNode(node.__html, node.__key) 30 | } 31 | 32 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 33 | createDOM(config: EditorConfig): HTMLElement { 34 | const div = document.createElement('div') 35 | return div 36 | } 37 | 38 | updateDOM(): false { 39 | return false 40 | } 41 | 42 | setHTML(html: string): void { 43 | const self = this.getWritable() 44 | self.__html = html 45 | } 46 | 47 | getHTML(): string { 48 | const self = this.getLatest() 49 | return self.__html 50 | } 51 | 52 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 53 | decorate(editor: LexicalEditor): ReactNode { 54 | return 55 | } 56 | 57 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 58 | exportDOM(editor: LexicalEditor): DOMExportOutput { 59 | const div = document.createElement('div') 60 | div.className = 'editor-email-quote' 61 | let innerHTML = this.__html.trim() 62 | if (innerHTML.startsWith(`
`)) { 63 | innerHTML = innerHTML.replace(`
`, '') 64 | innerHTML = innerHTML.slice(0, -6) //
65 | } 66 | div.innerHTML = innerHTML.trim() 67 | return { element: div } 68 | } 69 | 70 | static importDOM(): DOMConversionMap | null { 71 | return { 72 | div: (node: Node) => { 73 | if ( 74 | node instanceof HTMLDivElement && 75 | node.classList.contains('editor-email-quote') 76 | ) { 77 | return { 78 | conversion: convertEmailQuoteElement, 79 | priority: 3 80 | } 81 | } 82 | return null 83 | } 84 | } 85 | } 86 | 87 | exportJSON(): SerializedEmailQuoteNode { 88 | return { 89 | html: this.__html, 90 | type: 'emailquote', 91 | version: 1 92 | } 93 | } 94 | 95 | static importJSON(serializedNode: SerializedEmailQuoteNode): EmailQuoteNode { 96 | const node = $createEmailQuoteNode(serializedNode.html) 97 | return node 98 | } 99 | } 100 | 101 | type SerializedEmailQuoteNode = SerializedLexicalNode & { 102 | html: string 103 | } 104 | 105 | export function $createEmailQuoteNode(html: string): EmailQuoteNode { 106 | return new EmailQuoteNode(html) 107 | } 108 | 109 | export function $isEmailQuoteNode(node: LexicalNode): boolean { 110 | return node instanceof EmailQuoteNode 111 | } 112 | 113 | function convertEmailQuoteElement(domNode: Node): DOMConversionOutput { 114 | const node = $createEmailQuoteNode( 115 | domNode.firstChild?.parentElement?.outerHTML ?? '' 116 | ) 117 | return { node } 118 | } 119 | 120 | interface EmailQuoteProps { 121 | html: string 122 | } 123 | 124 | export function EmailQuote(props: EmailQuoteProps) { 125 | return <>{parseEmailHTML(props.html)} 126 | } 127 | -------------------------------------------------------------------------------- /web/src/components/inputs/RichTextEditor.css: -------------------------------------------------------------------------------- 1 | .editor-text-bold { 2 | font-weight: bold; 3 | } 4 | 5 | .editor-text-italic { 6 | font-style: italic; 7 | } 8 | 9 | .editor-text-underline { 10 | text-decoration: underline; 11 | } 12 | 13 | .editor-text-strikethrough { 14 | text-decoration: line-through; 15 | } 16 | 17 | .editor-text-underlineStrikethrough { 18 | text-decoration: underline line-through; 19 | } 20 | 21 | .editor-text-code { 22 | background-color: rgb(240, 242, 245); 23 | padding: 1px 0.25rem; 24 | font-family: Menlo, Consolas, Monaco, monospace; 25 | font-size: 94%; 26 | } 27 | 28 | .editor-link { 29 | color: rgb(8 145 178); 30 | text-decoration: none; 31 | } 32 | 33 | .tree-view-output { 34 | display: block; 35 | background: #222; 36 | color: #fff; 37 | padding: 5px; 38 | font-size: 12px; 39 | white-space: pre-wrap; 40 | margin: 1px auto 10px auto; 41 | max-height: 250px; 42 | position: relative; 43 | border-bottom-left-radius: 10px; 44 | border-bottom-right-radius: 10px; 45 | overflow: auto; 46 | line-height: 14px; 47 | } 48 | 49 | .editor-code { 50 | background-color: rgb(240, 242, 245); 51 | font-family: Menlo, Consolas, Monaco, monospace; 52 | display: block; 53 | padding: 8px 8px 8px 52px; 54 | line-height: 1.53; 55 | font-size: 13px; 56 | margin: 0; 57 | margin-top: 8px; 58 | margin-bottom: 8px; 59 | tab-size: 2; 60 | /* white-space: pre; */ 61 | overflow-x: auto; 62 | position: relative; 63 | } 64 | 65 | .editor-code:before { 66 | content: attr(data-gutter); 67 | position: absolute; 68 | background-color: #eee; 69 | left: 0; 70 | top: 0; 71 | border-right: 1px solid #ccc; 72 | padding: 8px; 73 | color: #777; 74 | white-space: pre-wrap; 75 | text-align: right; 76 | min-width: 25px; 77 | } 78 | .editor-code:after { 79 | content: attr(data-highlight-language); 80 | top: 0; 81 | right: 3px; 82 | padding: 3px; 83 | font-size: 10px; 84 | text-transform: uppercase; 85 | position: absolute; 86 | color: rgba(0, 0, 0, 0.5); 87 | } 88 | 89 | .editor-tokenComment { 90 | color: slategray; 91 | } 92 | 93 | .editor-tokenPunctuation { 94 | color: #999; 95 | } 96 | 97 | .editor-tokenProperty { 98 | color: #905; 99 | } 100 | 101 | .editor-tokenSelector { 102 | color: #690; 103 | } 104 | 105 | .editor-tokenOperator { 106 | color: #9a6e3a; 107 | } 108 | 109 | .editor-tokenAttr { 110 | color: #07a; 111 | } 112 | 113 | .editor-tokenVariable { 114 | color: #e90; 115 | } 116 | 117 | .editor-tokenFunction { 118 | color: #dd4a68; 119 | } 120 | 121 | .editor-paragraph { 122 | margin: 0; 123 | margin-bottom: 8px; 124 | position: relative; 125 | } 126 | 127 | .editor-paragraph:last-child { 128 | margin-bottom: 0; 129 | } 130 | 131 | .editor-heading-h1 { 132 | font-size: 24px; 133 | color: rgb(5, 5, 5); 134 | font-weight: 400; 135 | margin: 0; 136 | margin-bottom: 12px; 137 | padding: 0; 138 | } 139 | 140 | .editor-heading-h2 { 141 | font-size: 16px; 142 | color: rgb(101, 103, 107); 143 | font-weight: 700; 144 | margin: 0; 145 | margin-top: 10px; 146 | padding: 0; 147 | } 148 | 149 | .editor-quote { 150 | margin: 0; 151 | margin-left: 20px; 152 | font-size: 15px; 153 | color: rgb(101, 103, 107); 154 | border-left-color: rgb(206, 208, 212); 155 | border-left-width: 4px; 156 | border-left-style: solid; 157 | padding-left: 16px; 158 | } 159 | 160 | .editor-list-ol { 161 | padding: 0; 162 | margin: 0; 163 | margin-left: 16px; 164 | list-style: decimal outside; 165 | } 166 | 167 | .editor-list-ul { 168 | padding: 0; 169 | margin: 0; 170 | margin-left: 16px; 171 | list-style: disc outside; 172 | } 173 | 174 | .editor-listitem { 175 | margin: 8px 32px 8px 32px; 176 | display: list-item; 177 | } 178 | 179 | .editor-nested-listitem { 180 | list-style-type: none; 181 | } 182 | 183 | @media (prefers-color-scheme: dark) { 184 | .editor-heading-h1 { 185 | color: rgb(240, 242, 245); 186 | } 187 | .editor-heading-h2 { 188 | color: rgb(223, 225, 228); 189 | } 190 | .editor-text-code { 191 | background-color: rgb(40, 42, 45); 192 | color: rgb(240, 242, 245); 193 | } 194 | .editor-link { 195 | color: rgb(6, 182, 212); 196 | } 197 | .editor-code { 198 | background-color: rgb(40, 42, 45); 199 | color: rgb(240, 242, 245); 200 | } 201 | .editor-code:before { 202 | background-color: #333; 203 | border-right: 1px solid #444; 204 | color: #999; 205 | } 206 | .editor-code:after { 207 | color: #848687; 208 | } 209 | } 210 | 211 | pre::-webkit-scrollbar { 212 | background: transparent; 213 | width: 10px; 214 | } 215 | 216 | pre::-webkit-scrollbar-thumb { 217 | background: #999; 218 | } 219 | 220 | .editor-email-quote { 221 | border-left: solid 1px #ccc; 222 | padding-left: 10px; 223 | } 224 | -------------------------------------------------------------------------------- /web/src/components/inputs/RichTextEditor.tsx: -------------------------------------------------------------------------------- 1 | import { CodeHighlightNode, CodeNode } from '@lexical/code' 2 | import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html' 3 | import { AutoLinkNode, LinkNode } from '@lexical/link' 4 | import { ListItemNode, ListNode } from '@lexical/list' 5 | import { TRANSFORMERS } from '@lexical/markdown' 6 | import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin' 7 | import { 8 | InitialConfigType, 9 | LexicalComposer 10 | } from '@lexical/react/LexicalComposer' 11 | import { ContentEditable } from '@lexical/react/LexicalContentEditable' 12 | import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' 13 | import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' 14 | import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin' 15 | import { ListPlugin } from '@lexical/react/LexicalListPlugin' 16 | import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin' 17 | import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' 18 | import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin' 19 | import { HeadingNode, QuoteNode } from '@lexical/rich-text' 20 | import { TableCellNode, TableNode, TableRowNode } from '@lexical/table' 21 | import { $getRoot, EditorState, LexicalEditor } from 'lexical' 22 | 23 | import { EmailQuoteNode } from 'components/inputs/EmailQuoteNode' 24 | import 'components/inputs/RichTextEditor.css' 25 | import AutoLinkPlugin from 'components/inputs/plugins/AutoLinkPlugin' 26 | import CodeHighlightPlugin from 'components/inputs/plugins/CodeHighlightPlugin' 27 | import ListMaxIndentLevelPlugin from 'components/inputs/plugins/ListMaxIndentLevelPlugin' 28 | import ToolbarPlugin from 'components/inputs/plugins/ToolbarPlugin' 29 | import theme from 'components/inputs/themes/LexicalTheme' 30 | 31 | function Placeholder() { 32 | return ( 33 |
34 | Email body... 35 |
36 | ) 37 | } 38 | 39 | interface RichTextEditorProps { 40 | initialHtml: string 41 | handleChange: ({ html, text }: { html: string; text: string }) => void 42 | handleSend: () => void 43 | handleDelete: () => void 44 | } 45 | 46 | export default function RichTextEditor(props: RichTextEditorProps) { 47 | const updateHTML = (editor: LexicalEditor, value: string, clear: boolean) => { 48 | const root = $getRoot() 49 | const parser = new DOMParser() 50 | const dom = parser.parseFromString(value, 'text/html') 51 | const nodes = $generateNodesFromDOM(editor, dom) 52 | if (clear) { 53 | root.clear() 54 | } 55 | root.append(...nodes) 56 | } 57 | 58 | const editorConfig: InitialConfigType = { 59 | theme, 60 | namespace: 'email-editor', 61 | onError(error: Error) { 62 | throw error 63 | }, 64 | nodes: [ 65 | HeadingNode, 66 | ListNode, 67 | ListItemNode, 68 | QuoteNode, 69 | CodeNode, 70 | CodeHighlightNode, 71 | TableNode, 72 | TableCellNode, 73 | TableRowNode, 74 | AutoLinkNode, 75 | LinkNode, 76 | EmailQuoteNode 77 | ], 78 | editorState: (editor) => { 79 | if (!props.initialHtml) return undefined 80 | updateHTML(editor, props.initialHtml, true) 81 | } 82 | } 83 | 84 | const onChange = (_: EditorState, editor: LexicalEditor) => { 85 | editor.update(() => { 86 | const html = `${$generateHtmlFromNodes( 87 | editor, 88 | null 89 | )}` 90 | const text = $getRoot().getTextContent() 91 | props.handleChange({ 92 | html, 93 | text 94 | }) 95 | }) 96 | } 97 | 98 | return ( 99 | 100 |
101 |
102 | 110 | } 111 | placeholder={} 112 | ErrorBoundary={LexicalErrorBoundary} 113 | /> 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 |
124 | 128 |
129 |
130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /web/src/components/inputs/TextInput.tsx: -------------------------------------------------------------------------------- 1 | export interface EmailAddressInputProps { 2 | value: string 3 | placeholder?: string 4 | handleChange: (value: string) => void 5 | } 6 | 7 | export default function EmailAddressInput(props: EmailAddressInputProps) { 8 | const { value, placeholder, handleChange } = props 9 | 10 | return ( 11 | 12 | { 18 | handleChange(e.target.value) 19 | }} 20 | > 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/Bars3BottomCenterIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from './IconProps' 2 | 3 | export default function Bars3BottomCenterIcon({ className }: IconProps) { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/BoldIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 harryzcy 3 | * Copyright (c) 2011-2022 The Bootstrap Authors 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | import { IconProps } from './IconProps' 7 | 8 | export default function BoldIcon({ className }: IconProps) { 9 | return ( 10 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/IconProps.ts: -------------------------------------------------------------------------------- 1 | export interface IconProps { 2 | className?: string 3 | } 4 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/ItalicIcon.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 harryzcy 3 | * Copyright (c) 2011-2022 The Bootstrap Authors 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | import { IconProps } from './IconProps' 7 | 8 | export default function ItalicIcon({ className }: IconProps) { 9 | return ( 10 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/ListNumberIcon.tsx: -------------------------------------------------------------------------------- 1 | import { IconProps } from './IconProps' 2 | 3 | export default function ListNumberIcon({ className }: IconProps) { 4 | return ( 5 | 11 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/StrikeThrough.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 harryzcy 3 | * Copyright (c) 2011-2022 The Bootstrap Authors 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | import { IconProps } from './IconProps' 7 | 8 | export default function StrikeThroughIcon({ className }: IconProps) { 9 | return ( 10 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/inputs/icons/Underline.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 harryzcy 3 | * Copyright (c) 2011-2022 The Bootstrap Authors 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | import { IconProps } from './IconProps' 7 | 8 | export default function UnderlineIcon({ className }: IconProps) { 9 | return ( 10 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /web/src/components/inputs/plugins/AutoLinkPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin' 2 | 3 | const URL_MATCHER = 4 | /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/ 5 | 6 | const EMAIL_MATCHER = 7 | /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ 8 | 9 | const MATCHERS = [ 10 | (text: string) => { 11 | const match = URL_MATCHER.exec(text) 12 | return ( 13 | match && { 14 | index: match.index, 15 | length: match[0].length, 16 | text: match[0], 17 | url: match[0] 18 | } 19 | ) 20 | }, 21 | (text: string) => { 22 | const match = EMAIL_MATCHER.exec(text) 23 | return ( 24 | match && { 25 | index: match.index, 26 | length: match[0].length, 27 | text: match[0], 28 | url: `mailto:${match[0]}` 29 | } 30 | ) 31 | } 32 | ] 33 | 34 | export default function CustomAutoLinkPlugin() { 35 | return 36 | } 37 | -------------------------------------------------------------------------------- /web/src/components/inputs/plugins/CodeHighlightPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { registerCodeHighlighting } from '@lexical/code' 2 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' 3 | import { useEffect } from 'react' 4 | 5 | export default function CodeHighlightPlugin() { 6 | const [editor] = useLexicalComposerContext() 7 | useEffect(() => { 8 | return registerCodeHighlighting(editor) 9 | }, [editor]) 10 | return null 11 | } 12 | -------------------------------------------------------------------------------- /web/src/components/inputs/plugins/ListMaxIndentLevelPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list' 2 | import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' 3 | import { 4 | $getSelection, 5 | $isElementNode, 6 | $isRangeSelection, 7 | COMMAND_PRIORITY_HIGH, 8 | INDENT_CONTENT_COMMAND, 9 | RangeSelection 10 | } from 'lexical' 11 | import { useEffect } from 'react' 12 | 13 | function getElementNodesInSelection(selection: RangeSelection) { 14 | const nodesInSelection = selection.getNodes() 15 | 16 | if (nodesInSelection.length === 0) { 17 | return new Set([ 18 | selection.anchor.getNode().getParentOrThrow(), 19 | selection.focus.getNode().getParentOrThrow() 20 | ]) 21 | } 22 | 23 | return new Set( 24 | nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())) 25 | ) 26 | } 27 | 28 | function isIndentPermitted(maxDepth: number) { 29 | const selection = $getSelection() 30 | 31 | if (!$isRangeSelection(selection)) { 32 | return false 33 | } 34 | 35 | const elementNodesInSelection = getElementNodesInSelection(selection) 36 | 37 | let totalDepth = 0 38 | 39 | for (const elementNode of elementNodesInSelection) { 40 | if ($isListNode(elementNode)) { 41 | totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth) 42 | } else if ($isListItemNode(elementNode)) { 43 | const parent = elementNode.getParent() 44 | if (!$isListNode(parent)) { 45 | throw new Error( 46 | 'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.' 47 | ) 48 | } 49 | 50 | totalDepth = Math.max($getListDepth(parent) + 1, totalDepth) 51 | } 52 | } 53 | 54 | return totalDepth <= maxDepth 55 | } 56 | 57 | export default function ListMaxIndentLevelPlugin({ 58 | maxDepth 59 | }: { 60 | maxDepth: number | null 61 | }) { 62 | const [editor] = useLexicalComposerContext() 63 | 64 | useEffect(() => { 65 | return editor.registerCommand( 66 | INDENT_CONTENT_COMMAND, 67 | () => !isIndentPermitted(maxDepth ?? 7), 68 | COMMAND_PRIORITY_HIGH 69 | ) 70 | }, [editor, maxDepth]) 71 | 72 | return null 73 | } 74 | -------------------------------------------------------------------------------- /web/src/components/inputs/themes/LexicalTheme.ts: -------------------------------------------------------------------------------- 1 | import { type EditorThemeClasses } from 'lexical' 2 | 3 | const theme: EditorThemeClasses = { 4 | ltr: 'ltr', 5 | rtl: 'rtl', 6 | placeholder: 'editor-placeholder', 7 | paragraph: 'editor-paragraph', 8 | quote: 'editor-quote', 9 | heading: { 10 | h1: 'editor-heading-h1', 11 | h2: 'editor-heading-h2', 12 | h3: 'editor-heading-h3', 13 | h4: 'editor-heading-h4', 14 | h5: 'editor-heading-h5', 15 | h6: 'editor-heading-h6' 16 | }, 17 | list: { 18 | nested: { 19 | listitem: 'editor-nested-listitem' 20 | }, 21 | ol: 'editor-list-ol', 22 | ul: 'editor-list-ul', 23 | listitem: 'editor-listItem', 24 | listitemChecked: 'editor-listItemChecked', 25 | listitemUnchecked: 'editor-listItemUnchecked' 26 | }, 27 | hashtag: 'editor-hashtag', 28 | image: 'editor-image', 29 | link: 'editor-link', 30 | text: { 31 | bold: 'editor-textBold', 32 | code: 'editor-textCode', 33 | italic: 'editor-textItalic', 34 | strikethrough: 'editor-textStrikethrough', 35 | subscript: 'editor-textSubscript', 36 | superscript: 'editor-textSuperscript', 37 | underline: 'editor-textUnderline', 38 | underlineStrikethrough: 'editor-textUnderlineStrikethrough' 39 | }, 40 | code: 'editor-code', 41 | codeHighlight: { 42 | atrule: 'editor-tokenAttr', 43 | attr: 'editor-tokenAttr', 44 | boolean: 'editor-tokenProperty', 45 | builtin: 'editor-tokenSelector', 46 | cdata: 'editor-tokenComment', 47 | char: 'editor-tokenSelector', 48 | class: 'editor-tokenFunction', 49 | 'class-name': 'editor-tokenFunction', 50 | comment: 'editor-tokenComment', 51 | constant: 'editor-tokenProperty', 52 | deleted: 'editor-tokenProperty', 53 | doctype: 'editor-tokenComment', 54 | entity: 'editor-tokenOperator', 55 | function: 'editor-tokenFunction', 56 | important: 'editor-tokenVariable', 57 | inserted: 'editor-tokenSelector', 58 | keyword: 'editor-tokenAttr', 59 | namespace: 'editor-tokenVariable', 60 | number: 'editor-tokenProperty', 61 | operator: 'editor-tokenOperator', 62 | prolog: 'editor-tokenComment', 63 | property: 'editor-tokenProperty', 64 | punctuation: 'editor-tokenPunctuation', 65 | regex: 'editor-tokenVariable', 66 | selector: 'editor-tokenSelector', 67 | string: 'editor-tokenSelector', 68 | symbol: 'editor-tokenProperty', 69 | tag: 'editor-tokenProperty', 70 | url: 'editor-tokenOperator', 71 | variable: 'editor-tokenVariable' 72 | } 73 | } 74 | 75 | export default theme 76 | -------------------------------------------------------------------------------- /web/src/components/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /web/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes' 2 | import { Toaster as Sonner, ToasterProps } from 'sonner' 3 | 4 | const Toaster = ({ ...props }: ToasterProps) => { 5 | const { theme = 'system' } = useTheme() 6 | 7 | return ( 8 | 25 | ) 26 | } 27 | 28 | export { Toaster } 29 | -------------------------------------------------------------------------------- /web/src/contexts/ConfigContext.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, createContext } from 'react' 2 | 3 | import { Config } from 'services/config' 4 | 5 | export interface Plugin { 6 | name: string 7 | displayName: string 8 | endpoints: { 9 | email: string 10 | emails: string 11 | } 12 | } 13 | 14 | export interface State { 15 | config: Config 16 | loaded: boolean 17 | } 18 | 19 | export interface Action { 20 | type: 'set' 21 | config: Config 22 | } 23 | 24 | export const initialConfigState: State = { 25 | config: { 26 | emailAddresses: [], 27 | disableProxy: false, 28 | imagesAutoLoad: false, 29 | plugins: [] 30 | }, 31 | loaded: false 32 | } 33 | 34 | export function configReducer(state: State, action: Action): State { 35 | switch (action.type) { 36 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 37 | case 'set': 38 | return { config: action.config, loaded: true } 39 | default: 40 | return state 41 | } 42 | } 43 | 44 | export const ConfigContext = createContext<{ 45 | state: State 46 | dispatch: Dispatch 47 | }>({ 48 | state: initialConfigState, 49 | dispatch: () => null 50 | }) 51 | -------------------------------------------------------------------------------- /web/src/contexts/DraftEmailContext.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, createContext } from 'react' 2 | 3 | import { Email } from 'services/emails' 4 | 5 | import { formatDateFull } from 'utils/time' 6 | 7 | export interface DraftEmail { 8 | messageID: string 9 | subject: string 10 | from: string[] 11 | to: string[] 12 | cc: string[] 13 | bcc: string[] 14 | replyTo: string[] 15 | text: string 16 | html: string 17 | replyEmail?: Email 18 | threadID?: string 19 | } 20 | 21 | export interface State { 22 | activeEmail: DraftEmail | null 23 | emails: DraftEmail[] 24 | } 25 | 26 | export const initialState: State = { 27 | activeEmail: null, 28 | emails: [] 29 | } 30 | 31 | export type Action = 32 | | { 33 | // new email that's not a reply or forward 34 | type: 'new' 35 | messageID: string 36 | } 37 | | { 38 | // new reply email 39 | type: 'new-reply' 40 | messageID: string 41 | allowedAddresses: string[] // the allowed addresses to send from 42 | replyEmail: Email 43 | } 44 | | { 45 | // new forward email 46 | type: 'new-forward' 47 | messageID: string 48 | forwardEmail: Email 49 | } 50 | | { 51 | // open an email already in the working directory 52 | type: 'open' 53 | messageID: string 54 | } 55 | | { 56 | // load an email to the working directory 57 | type: 'load' 58 | email: DraftEmail 59 | } 60 | | { 61 | // exit the full screen email view 62 | type: 'minimize' 63 | } 64 | | { 65 | // remove an email from the working directory 66 | type: 'remove' 67 | messageID: string 68 | } 69 | | { 70 | // update an email in the working directory 71 | type: 'update' 72 | messageID: string // the original messageID of the email to update 73 | email: DraftEmail 74 | } 75 | 76 | /** 77 | * Create a new empty email. 78 | * @returns the new draft email 79 | */ 80 | function newEmptyEmail(messageID: string): DraftEmail { 81 | return { 82 | messageID, 83 | subject: '', 84 | from: [] as string[], 85 | to: [] as string[], 86 | cc: [] as string[], 87 | bcc: [] as string[] 88 | } as DraftEmail 89 | } 90 | 91 | /** 92 | * Add a prefix to a subject only if it doesn't already have it. 93 | * It's useful for adding "Re: " or "Fwd: " to a subject. 94 | * @returns the subject with the prefix 95 | */ 96 | function addPrefix(subject: string, prefix: string) { 97 | return subject.startsWith(prefix) ? subject : prefix + subject 98 | } 99 | 100 | export function draftEmailReducer(state: State, action: Action): State { 101 | switch (action.type) { 102 | case 'new': { 103 | const newEmail = newEmptyEmail(action.messageID) 104 | return { 105 | activeEmail: newEmail, 106 | emails: [...state.emails, newEmail] 107 | } 108 | } 109 | 110 | case 'new-reply': { 111 | const newEmail = newEmptyEmail(action.messageID) 112 | newEmail.replyEmail = action.replyEmail 113 | 114 | if (action.replyEmail.type === 'inbox') { 115 | newEmail.from = determineFromAddress( 116 | action.replyEmail.to, 117 | action.allowedAddresses 118 | ) 119 | newEmail.to = [action.replyEmail.from[0]] 120 | } else { 121 | // sent 122 | newEmail.from = [action.replyEmail.from[0]] 123 | newEmail.to = [action.replyEmail.to[0]] 124 | } 125 | newEmail.subject = addPrefix(action.replyEmail.subject, 'Re: ') 126 | 127 | return { 128 | activeEmail: newEmail, 129 | emails: [...state.emails, newEmail] 130 | } 131 | } 132 | 133 | case 'new-forward': { 134 | const newEmail = newEmptyEmail(action.messageID) 135 | if (action.forwardEmail.type === 'inbox') { 136 | newEmail.from = [action.forwardEmail.to[0]] 137 | } else { 138 | // sent 139 | newEmail.from = [action.forwardEmail.from[0]] 140 | } 141 | newEmail.subject = action.forwardEmail.subject.startsWith('Fwd: ') 142 | ? action.forwardEmail.subject 143 | : `Fwd: ${action.forwardEmail.subject}` 144 | newEmail.html = createForwardHTML(action.forwardEmail) 145 | return { 146 | activeEmail: newEmail, 147 | emails: [...state.emails, newEmail] 148 | } 149 | } 150 | 151 | case 'open': 152 | return { 153 | activeEmail: 154 | state.emails.find((email) => email.messageID === action.messageID) ?? 155 | null, 156 | emails: state.emails 157 | } 158 | 159 | case 'load': { 160 | const foundEmail = state.emails.find( 161 | (email) => email.messageID === action.email.messageID 162 | ) 163 | if (foundEmail) { 164 | return { 165 | activeEmail: foundEmail, 166 | emails: state.emails 167 | } 168 | } 169 | const email = { 170 | ...action.email, 171 | html: extractEmailBody(action.email.html) 172 | } 173 | return { 174 | activeEmail: email, 175 | emails: [...state.emails, email] 176 | } 177 | } 178 | 179 | case 'minimize': 180 | return { 181 | activeEmail: null, 182 | emails: state.emails 183 | } 184 | 185 | case 'remove': 186 | // if the active email is the one being removed, set it to null 187 | // otherwise, keep it the same 188 | return { 189 | activeEmail: 190 | state.activeEmail?.messageID == action.messageID 191 | ? null 192 | : state.activeEmail, 193 | emails: state.emails.filter( 194 | (email) => email.messageID !== action.messageID 195 | ) 196 | } 197 | 198 | case 'update': { 199 | const updatedEmails = state.emails.map((email) => { 200 | if (email.messageID === action.messageID) { 201 | return { 202 | ...action.email, 203 | replyEmail: email.replyEmail, 204 | threadID: email.threadID 205 | } 206 | } 207 | return email 208 | }) 209 | return { 210 | activeEmail: 211 | state.activeEmail?.messageID === action.messageID 212 | ? { 213 | ...action.email, 214 | replyEmail: state.activeEmail.replyEmail, 215 | threadID: state.activeEmail.threadID 216 | } 217 | : state.activeEmail, 218 | emails: updatedEmails 219 | } 220 | } 221 | } 222 | } 223 | 224 | export const DraftEmailsContext = createContext<{ 225 | activeEmail: DraftEmail | null 226 | emails: DraftEmail[] 227 | dispatch: Dispatch 228 | }>({ 229 | activeEmail: null, 230 | emails: [], 231 | dispatch: () => null 232 | }) 233 | 234 | const extractEmailBody = (html?: string) => { 235 | if (!html) return '' 236 | if (html.includes('')) { 237 | const body = /(.*?)<\/body>/gs.exec(html)?.[1] ?? '' 238 | return body 239 | } 240 | return html 241 | } 242 | 243 | const createForwardHTML = (email: Email): string => { 244 | const { html, timeReceived, timeSent, from } = email 245 | const time = timeReceived || timeSent 246 | 247 | const fromStr = from 248 | .map((raw) => { 249 | const { name, address } = parseAddress(raw) 250 | return name ? `${name} <${address}>` : address // < and > are < and > respectively 251 | }) 252 | .join(', ') 253 | 254 | const body = extractEmailBody(html) 255 | const forwardHTML = ` 256 |


257 |

On ${formatDateFull(time)} ${fromStr} wrote:

258 |
${body}
` 259 | return forwardHTML 260 | } 261 | 262 | const parseAddress = ( 263 | address: string 264 | ): { 265 | name: string 266 | address: string 267 | } => { 268 | address = address.trim() 269 | 270 | let displayName = '' 271 | let email = '' 272 | 273 | const countSubstring = (str: string, sub: string) => { 274 | return str.split(sub).length - 1 275 | } 276 | if (countSubstring(address, '<') == 1 && countSubstring(address, '>') == 1) { 277 | displayName = address.split('<')[0].trim() 278 | email = address.split('<')[1].replaceAll('>', '').trim() 279 | 280 | if (displayName.startsWith('"') && displayName.endsWith('"')) { 281 | displayName = displayName.slice(1, -1) 282 | } 283 | } else { 284 | email = address 285 | } 286 | 287 | return { 288 | name: displayName, 289 | address: email 290 | } 291 | } 292 | 293 | const determineFromAddress = ( 294 | choices: string[], 295 | allowedAddresses?: string[] 296 | ): string[] => { 297 | if (!allowedAddresses) { 298 | // TODO: handle this better 299 | console.warn("Couldn't find a matching email address.") 300 | return [choices[0]] 301 | } 302 | 303 | for (const address of choices) { 304 | for (const expectedAddress of allowedAddresses) { 305 | const hasPrefix = expectedAddress.includes('@') 306 | if (hasPrefix) { 307 | if (address === expectedAddress) { 308 | return [address] 309 | } 310 | } else { 311 | const domain = address.split('@')[1] 312 | if (domain === expectedAddress) { 313 | return [address] 314 | } 315 | } 316 | } 317 | } 318 | 319 | // TODO: handle this better 320 | console.warn("Couldn't find a matching email address.") 321 | return [choices[0]] 322 | } 323 | -------------------------------------------------------------------------------- /web/src/hooks/useIsInViewport.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react' 2 | 3 | export default function useIsInViewport( 4 | ref: React.RefObject 5 | ) { 6 | const [isIntersecting, setIsIntersecting] = useState(false) 7 | 8 | const observer = useMemo( 9 | () => 10 | new IntersectionObserver(([entry]) => { 11 | setIsIntersecting(entry.isIntersecting) 12 | }), 13 | [ref] 14 | ) 15 | 16 | useEffect(() => { 17 | if (ref.current === null) return 18 | observer.observe(ref.current) 19 | // Remove the observer as soon as the component is unmounted 20 | return () => { 21 | observer.disconnect() 22 | } 23 | }, [ref, observer]) 24 | 25 | return isIntersecting 26 | } 27 | -------------------------------------------------------------------------------- /web/src/hooks/useOutsideClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | /** 4 | * Hook that listens clicks outside of the passed ref 5 | */ 6 | export function useOutsideClick( 7 | refs: 8 | | React.RefObject 9 | | React.RefObject[], 10 | callback: () => void 11 | ) { 12 | useEffect(() => { 13 | function handleClickOutside(event: MouseEvent) { 14 | if (Array.isArray(refs)) { 15 | let contained = false 16 | refs.forEach((ref) => { 17 | // @eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 18 | if (!ref.current || ref.current.contains(event.target as Node)) { 19 | contained = true 20 | } 21 | }) 22 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 23 | if (!contained) { 24 | callback() 25 | } 26 | } else { 27 | // @eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 28 | if (refs.current && !refs.current.contains(event.target as Node)) { 29 | callback() 30 | } 31 | } 32 | } 33 | 34 | document.addEventListener('mousedown', handleClickOutside) 35 | return () => { 36 | document.removeEventListener('mousedown', handleClickOutside) 37 | } 38 | }, [refs]) 39 | } 40 | -------------------------------------------------------------------------------- /web/src/hooks/useThrottled.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react' 2 | 3 | const DEFAULT_THROTTLE_MS = 3000 4 | 5 | const getRemainingTime = (lastTriggeredTime: number, throttleMs: number) => { 6 | const elapsedTime = Date.now() - lastTriggeredTime 7 | const remainingTime = throttleMs - elapsedTime 8 | 9 | return remainingTime < 0 ? 0 : remainingTime 10 | } 11 | 12 | const useThrottled = ( 13 | value: T, 14 | throttleMs: number = DEFAULT_THROTTLE_MS 15 | ) => { 16 | const [throttledValue, setThrottledValue] = useState(value) 17 | const lastTriggered = useRef(Date.now()) 18 | const timeoutRef = useRef | null>(null) 19 | 20 | const cancel = useCallback(() => { 21 | if (timeoutRef.current) { 22 | clearTimeout(timeoutRef.current) 23 | timeoutRef.current = null 24 | } 25 | }, []) 26 | 27 | useEffect(() => { 28 | let remainingTime = getRemainingTime(lastTriggered.current, throttleMs) 29 | 30 | if (remainingTime === 0) { 31 | lastTriggered.current = Date.now() 32 | setThrottledValue(value) 33 | cancel() 34 | } else { 35 | timeoutRef.current ??= setTimeout(() => { 36 | remainingTime = getRemainingTime(lastTriggered.current, throttleMs) 37 | 38 | if (remainingTime === 0) { 39 | lastTriggered.current = Date.now() 40 | setThrottledValue(value) 41 | cancel() 42 | } 43 | }, remainingTime) 44 | } 45 | 46 | return cancel 47 | }, [cancel, throttleMs, value]) 48 | 49 | return throttledValue 50 | } 51 | 52 | export default useThrottled 53 | -------------------------------------------------------------------------------- /web/src/index.css: -------------------------------------------------------------------------------- 1 | @import './preflight.css' layer(base); 2 | 3 | @import 'tailwindcss'; 4 | 5 | @plugin 'tailwindcss-animate'; 6 | 7 | @custom-variant dark (&:is(.dark *)); 8 | 9 | :root { 10 | --background: oklch(1 0 0); 11 | --foreground: oklch(0.145 0 0); 12 | --card: oklch(1 0 0); 13 | --card-foreground: oklch(0.145 0 0); 14 | --popover: oklch(1 0 0); 15 | --popover-foreground: oklch(0.145 0 0); 16 | --primary: oklch(0.205 0 0); 17 | --primary-foreground: oklch(0.985 0 0); 18 | --secondary: oklch(0.97 0 0); 19 | --secondary-foreground: oklch(0.205 0 0); 20 | --muted: oklch(0.97 0 0); 21 | --muted-foreground: oklch(0.556 0 0); 22 | --accent: oklch(0.97 0 0); 23 | --accent-foreground: oklch(0.205 0 0); 24 | --destructive: oklch(0.577 0.245 27.325); 25 | --destructive-foreground: oklch(0.577 0.245 27.325); 26 | --border: oklch(0.922 0 0); 27 | --input: oklch(0.922 0 0); 28 | --ring: oklch(0.708 0 0); 29 | --chart-1: oklch(0.646 0.222 41.116); 30 | --chart-2: oklch(0.6 0.118 184.704); 31 | --chart-3: oklch(0.398 0.07 227.392); 32 | --chart-4: oklch(0.828 0.189 84.429); 33 | --chart-5: oklch(0.769 0.188 70.08); 34 | --radius: 0.625rem; 35 | --sidebar: oklch(0.985 0 0); 36 | --sidebar-foreground: oklch(0.145 0 0); 37 | --sidebar-primary: oklch(0.205 0 0); 38 | --sidebar-primary-foreground: oklch(0.985 0 0); 39 | --sidebar-accent: oklch(0.97 0 0); 40 | --sidebar-accent-foreground: oklch(0.205 0 0); 41 | --sidebar-border: oklch(0.922 0 0); 42 | --sidebar-ring: oklch(0.708 0 0); 43 | } 44 | 45 | .dark { 46 | --background: oklch(0.145 0 0); 47 | --foreground: oklch(0.985 0 0); 48 | --card: oklch(0.145 0 0); 49 | --card-foreground: oklch(0.985 0 0); 50 | --popover: oklch(0.145 0 0); 51 | --popover-foreground: oklch(0.985 0 0); 52 | --primary: oklch(0.985 0 0); 53 | --primary-foreground: oklch(0.205 0 0); 54 | --secondary: oklch(0.269 0 0); 55 | --secondary-foreground: oklch(0.985 0 0); 56 | --muted: oklch(0.269 0 0); 57 | --muted-foreground: oklch(0.708 0 0); 58 | --accent: oklch(0.269 0 0); 59 | --accent-foreground: oklch(0.985 0 0); 60 | --destructive: oklch(0.396 0.141 25.723); 61 | --destructive-foreground: oklch(0.637 0.237 25.331); 62 | --border: oklch(0.269 0 0); 63 | --input: oklch(0.269 0 0); 64 | --ring: oklch(0.439 0 0); 65 | --chart-1: oklch(0.488 0.243 264.376); 66 | --chart-2: oklch(0.696 0.17 162.48); 67 | --chart-3: oklch(0.769 0.188 70.08); 68 | --chart-4: oklch(0.627 0.265 303.9); 69 | --chart-5: oklch(0.645 0.246 16.439); 70 | --sidebar: oklch(0.205 0 0); 71 | --sidebar-foreground: oklch(0.985 0 0); 72 | --sidebar-primary: oklch(0.488 0.243 264.376); 73 | --sidebar-primary-foreground: oklch(0.985 0 0); 74 | --sidebar-accent: oklch(0.269 0 0); 75 | --sidebar-accent-foreground: oklch(0.985 0 0); 76 | --sidebar-border: oklch(0.269 0 0); 77 | --sidebar-ring: oklch(0.439 0 0); 78 | } 79 | 80 | @theme inline { 81 | --color-background: var(--background); 82 | --color-foreground: var(--foreground); 83 | --color-card: var(--card); 84 | --color-card-foreground: var(--card-foreground); 85 | --color-popover: var(--popover); 86 | --color-popover-foreground: var(--popover-foreground); 87 | --color-primary: var(--primary); 88 | --color-primary-foreground: var(--primary-foreground); 89 | --color-secondary: var(--secondary); 90 | --color-secondary-foreground: var(--secondary-foreground); 91 | --color-muted: var(--muted); 92 | --color-muted-foreground: var(--muted-foreground); 93 | --color-accent: var(--accent); 94 | --color-accent-foreground: var(--accent-foreground); 95 | --color-destructive: var(--destructive); 96 | --color-destructive-foreground: var(--destructive-foreground); 97 | --color-border: var(--border); 98 | --color-input: var(--input); 99 | --color-ring: var(--ring); 100 | --color-chart-1: var(--chart-1); 101 | --color-chart-2: var(--chart-2); 102 | --color-chart-3: var(--chart-3); 103 | --color-chart-4: var(--chart-4); 104 | --color-chart-5: var(--chart-5); 105 | --radius-sm: calc(var(--radius) - 4px); 106 | --radius-md: calc(var(--radius) - 2px); 107 | --radius-lg: var(--radius); 108 | --radius-xl: calc(var(--radius) + 4px); 109 | --color-sidebar: var(--sidebar); 110 | --color-sidebar-foreground: var(--sidebar-foreground); 111 | --color-sidebar-primary: var(--sidebar-primary); 112 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 113 | --color-sidebar-accent: var(--sidebar-accent); 114 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 115 | --color-sidebar-border: var(--sidebar-border); 116 | --color-sidebar-ring: var(--sidebar-ring); 117 | } 118 | 119 | @layer base { 120 | * { 121 | @apply border-border outline-ring/50; 122 | } 123 | body { 124 | @apply bg-background text-foreground; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | 4 | import App from './App' 5 | import './index.css' 6 | 7 | // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style 8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /web/src/pages/EmailList.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { toast } from 'sonner' 3 | 4 | import EmailMenuBar from 'components/emails/EmailMenuBar' 5 | import EmailTableView from 'components/emails/EmailTableView' 6 | 7 | import { useOutsideClick } from 'hooks/useOutsideClick' 8 | 9 | import { 10 | deleteEmail, 11 | readEmail, 12 | trashEmail, 13 | unreadEmail 14 | } from 'services/emails' 15 | 16 | import { getCurrentYearMonth } from 'utils/time' 17 | 18 | import { useInboxContext } from './EmailRoot' 19 | 20 | export default function EmailList() { 21 | const { 22 | count, 23 | setCount, 24 | hasMore, 25 | setHasMore, 26 | nextCursor, 27 | setNextCursor, 28 | emails, 29 | setEmails, 30 | year, 31 | month, 32 | loadEmails 33 | } = useInboxContext() 34 | 35 | const [selected, setSelected] = useState([]) 36 | 37 | const menuRef = useRef(null) 38 | const emailViewRef = useRef(null) 39 | useOutsideClick([menuRef, emailViewRef], () => { 40 | setSelected([]) 41 | }) 42 | const { scrollYPosition, setScrollYPosition } = useInboxContext() 43 | useEffect(() => { 44 | emailViewRef.current?.scrollTo(0, scrollYPosition) 45 | }, [emailViewRef.current]) 46 | 47 | const toggleSelected = (messageID: string) => { 48 | if (selected.includes(messageID)) { 49 | setSelected(selected.filter((s) => s !== messageID)) 50 | } else { 51 | setSelected([...selected, messageID]) 52 | } 53 | } 54 | 55 | const [hasPrevious, setHasPrevious] = useState(false) 56 | 57 | const goPrevious = async () => { 58 | if (!hasPrevious) return 59 | 60 | let newMonth = month + 1 61 | let newYear = year 62 | if (newMonth === 13) { 63 | newMonth = 1 64 | newYear = year + 1 65 | } 66 | 67 | try { 68 | const data = await loadEmails({ 69 | year: newYear, 70 | month: newMonth 71 | }) 72 | setEmails(data.items) 73 | setCount(data.count) 74 | setHasMore(data.hasMore) 75 | setNextCursor(data.nextCursor) 76 | } catch (e) { 77 | console.error('Failed to load emails', e) 78 | toast.error('Failed to load emails') 79 | } 80 | } 81 | 82 | const goNext = async () => { 83 | let newMonth = month - 1 84 | let newYear = year 85 | if (newMonth === 0) { 86 | newMonth = 12 87 | newYear = year - 1 88 | } 89 | 90 | try { 91 | const data = await loadEmails({ 92 | year: newYear, 93 | month: newMonth 94 | }) 95 | setEmails(data.items) 96 | setCount(data.count) 97 | setHasMore(data.hasMore) 98 | setNextCursor(data.nextCursor) 99 | } catch (e) { 100 | console.error('Failed to load emails', e) 101 | toast.error('Failed to load emails') 102 | } 103 | } 104 | 105 | useEffect(() => { 106 | setHasPrevious(checkHasPrevious()) 107 | }, [year, month]) 108 | 109 | const checkHasPrevious = () => { 110 | const { year: currentYear, month: currentMonth } = getCurrentYearMonth() 111 | return currentYear > year || (currentYear === year && currentMonth > month) 112 | } 113 | 114 | const loadMoreEmails = async () => { 115 | if (!hasMore) return 116 | try { 117 | const data = await loadEmails({ 118 | year, 119 | month, 120 | nextCursor 121 | }) 122 | setEmails([...emails, ...data.items]) 123 | setCount(data.count + count) 124 | setHasMore(data.hasMore) 125 | setNextCursor(data.nextCursor) 126 | } catch (e) { 127 | console.error('Failed to load emails', e) 128 | toast.error('Failed to load emails') 129 | } 130 | } 131 | 132 | const handleDelete = async () => { 133 | const emailsToBeDeleted = emails.filter((e) => 134 | selected.includes(e.messageID) 135 | ) 136 | for (const email of emailsToBeDeleted) { 137 | if (email.type === 'draft') { 138 | await deleteEmail(email.messageID) 139 | } else { 140 | await trashEmail(email.messageID) 141 | } 142 | } 143 | setEmails(emails.filter((e) => !selected.includes(e.messageID))) 144 | setSelected([]) 145 | } 146 | 147 | const handleRead = async () => { 148 | const selectedEmails = emails.filter((e) => selected.includes(e.messageID)) 149 | for (const email of selectedEmails) { 150 | if (!email.unread) continue 151 | try { 152 | await readEmail(email.messageID) 153 | } catch (e) { 154 | console.error('Failed to mark email as read', e) 155 | toast.error('Failed to mark email as read') 156 | } 157 | } 158 | setEmails( 159 | emails.map((e) => { 160 | if (selected.includes(e.messageID)) { 161 | return { 162 | ...e, 163 | unread: false 164 | } 165 | } 166 | return e 167 | }) 168 | ) 169 | setSelected([]) 170 | } 171 | 172 | const handleUnread = async () => { 173 | const selectedEmails = emails.filter((e) => selected.includes(e.messageID)) 174 | for (const email of selectedEmails) { 175 | if (email.unread) continue 176 | try { 177 | await unreadEmail(email.messageID) 178 | } catch (e) { 179 | console.error('Failed to mark email as unread', e) 180 | toast.error('Failed to mark email as unread') 181 | } 182 | } 183 | setEmails( 184 | emails.map((e) => { 185 | if (selected.includes(e.messageID)) { 186 | return { 187 | ...e, 188 | unread: true 189 | } 190 | } 191 | return e 192 | }) 193 | ) 194 | setSelected([]) 195 | } 196 | 197 | return ( 198 | <> 199 |
200 | { 203 | setSelected([]) 204 | }} 205 | showOperations={selected.length > 0} 206 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 207 | handleDelete={handleDelete} 208 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 209 | handleRead={handleRead} 210 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 211 | handleUnread={handleUnread} 212 | hasPrevious={hasPrevious} 213 | hasNext={true} 214 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 215 | goPrevious={goPrevious} 216 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 217 | goNext={goNext} 218 | > 219 | 220 | {year}-{month.toString().padStart(2, '0')} 221 | 222 | 223 |
224 |
{ 228 | if (!emailViewRef.current) return 229 | setScrollYPosition(emailViewRef.current.scrollTop) 230 | }} 231 | > 232 | 240 |
241 | 242 | ) 243 | } 244 | -------------------------------------------------------------------------------- /web/src/pages/EmailRawView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Await, useLoaderData } from 'react-router' 3 | import { toast } from 'sonner' 4 | 5 | import { Toaster } from '@ui/sonner' 6 | 7 | import { reparseEmail } from 'services/emails' 8 | 9 | export default function EmailRawView() { 10 | const data: { messageID: string; raw: string } = useLoaderData() 11 | 12 | const [isRequesting, setIsRequesting] = React.useState(false) 13 | 14 | const reparse = async () => { 15 | if (isRequesting) return 16 | setIsRequesting(true) 17 | 18 | try { 19 | await reparseEmail(data.messageID) 20 | toast.info('Re-parsed email', { 21 | duration: 5000 22 | }) 23 | } catch (e) { 24 | console.error('Failed to re-parse email', e) 25 | toast.error('Failed to re-parse email', { 26 | duration: 5000 27 | }) 28 | } 29 | 30 | setIsRequesting(false) 31 | } 32 | 33 | return ( 34 |
35 |
36 |

37 | Original Email 38 |

39 |
40 | 41 | MessageID 42 | 43 | 44 | {data.messageID} 45 | 46 |
47 |
48 | Loading...
}> 49 | 50 | {(raw: string) => ( 51 | <> 52 |
 53 |                     {raw}
 54 |                   
55 | 56 |
57 | 63 | Re-Parse 64 | 65 | { 69 | void navigator.clipboard.writeText(raw) 70 | }} 71 | > 72 | Copy 73 | 74 | { 78 | const blob = new Blob([raw], { type: 'message/rfc822' }) 79 | const url = URL.createObjectURL(blob) 80 | const a = document.createElement('a') 81 | a.href = url 82 | a.download = `${data.messageID}.eml` 83 | a.click() 84 | }} 85 | > 86 | Download 87 | 88 |
89 | 90 | )} 91 |
92 | 93 |
94 | 95 | 96 |
97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /web/src/pages/EmailRoot.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * EmailRoot.tsx 3 | * This is the root component for inbox, draft, and sent pages. 4 | */ 5 | import { useContext, useEffect, useState } from 'react' 6 | import { Outlet, useOutletContext } from 'react-router' 7 | 8 | import DraftEmailsTabs from 'components/emails/DraftEmailsTabs' 9 | import FullScreenContent from 'components/emails/FullScreenContent' 10 | 11 | import { ConfigContext } from 'contexts/ConfigContext' 12 | import { DraftEmailsContext } from 'contexts/DraftEmailContext' 13 | 14 | import { getConfig } from 'services/config' 15 | import { EmailInfo, ListEmailsResponse, listEmails } from 'services/emails' 16 | 17 | import { getCurrentYearMonth } from 'utils/time' 18 | 19 | interface InboxContext { 20 | count: number 21 | setCount: (count: number) => void 22 | hasMore: boolean 23 | setHasMore: (hasMore: boolean) => void 24 | nextCursor: string | undefined 25 | setNextCursor: (nextCursor: string | undefined) => void 26 | emails: EmailInfo[] 27 | setEmails: (emails: EmailInfo[]) => void 28 | loadingState: 'idle' | 'loading' | 'loaded' | 'error' 29 | setLoadingState: ( 30 | loadingState: 'idle' | 'loading' | 'loaded' | 'error' 31 | ) => void 32 | year: number 33 | setYear: (year: number) => void 34 | month: number 35 | setMonth: (month: number) => void 36 | loadEmails: (input: { 37 | year: number 38 | month: number 39 | nextCursor?: string 40 | }) => Promise 41 | markAsRead: (messageID: string) => void 42 | scrollYPosition: number 43 | setScrollYPosition: (yPosition: number) => void 44 | } 45 | 46 | interface EmailRootProps { 47 | type: 'inbox' | 'draft' | 'sent' 48 | } 49 | 50 | export default function EmailRoot(props: EmailRootProps) { 51 | const [count, setCount] = useState(0) 52 | const [hasMore, setHasMore] = useState(true) 53 | const [nextCursor, setNextCursor] = useState(undefined) 54 | const [emails, setEmails] = useState([]) 55 | const [scrollYPosition, setScrollYPosition] = useState(0) 56 | 57 | const [loadingState, setLoadingState] = useState< 58 | 'idle' | 'loading' | 'loaded' | 'error' 59 | >('idle') 60 | 61 | useEffect(() => { 62 | const abortController = new AbortController() 63 | setLoadingState('loading') 64 | void loadAndSetEmails() 65 | return () => { 66 | abortController.abort() 67 | } 68 | }, [props.type]) 69 | 70 | const { year: initialYear, month: initialMonth } = getCurrentYearMonth() 71 | const [year, setYear] = useState(initialYear) 72 | const [month, setMonth] = useState(initialMonth) 73 | 74 | const loadEmails = async (input: { 75 | year?: number 76 | month?: number 77 | nextCursor?: string 78 | }) => { 79 | const { nextCursor } = input 80 | 81 | const data = await listEmails({ 82 | type: props.type, 83 | year: input.year ?? year, 84 | month: input.month ?? month, 85 | order: 'desc', 86 | nextCursor 87 | }) 88 | 89 | if (input.year) { 90 | setYear(input.year) 91 | } 92 | if (input.month) { 93 | setMonth(input.month) 94 | } 95 | 96 | setLoadingState('loaded') 97 | return data 98 | } 99 | 100 | const loadAndSetEmails = async (nextCursor?: string) => { 101 | setLoadingState('loading') 102 | const data = await loadEmails({ 103 | nextCursor 104 | }) 105 | setEmails(data.items) 106 | setCount(data.count) 107 | setHasMore(data.hasMore) 108 | setNextCursor(data.nextCursor) 109 | } 110 | 111 | const removeEmailFromList = (messageID: string) => { 112 | setEmails(emails.filter((email) => email.messageID !== messageID)) 113 | } 114 | 115 | const markAsRead = (messageID: string) => { 116 | setEmails( 117 | emails.map((email) => { 118 | if (email.messageID === messageID) { 119 | return { 120 | ...email, 121 | unread: false 122 | } 123 | } 124 | return email 125 | }) 126 | ) 127 | } 128 | 129 | const outletContext: InboxContext = { 130 | count, 131 | setCount, 132 | hasMore, 133 | setHasMore, 134 | nextCursor, 135 | setNextCursor, 136 | emails, 137 | setEmails, 138 | loadingState, 139 | setLoadingState, 140 | year, 141 | setYear, 142 | month, 143 | setMonth, 144 | loadEmails, 145 | markAsRead, 146 | scrollYPosition, 147 | setScrollYPosition 148 | } 149 | 150 | const configContext = useContext(ConfigContext) 151 | const draftEmailsContext = useContext(DraftEmailsContext) 152 | 153 | const loadConfig = async () => { 154 | if (configContext.state.loaded) { 155 | return 156 | } 157 | configContext.dispatch({ 158 | type: 'set', 159 | config: await getConfig() 160 | }) 161 | } 162 | 163 | useEffect(() => { 164 | void loadConfig() 165 | }) 166 | 167 | return ( 168 | <> 169 |
0 173 | ? 'h-[calc(100%-3rem)]' 174 | : 'h-full') 175 | } 176 | > 177 |
178 |

179 | {props.type === 'inbox' 180 | ? 'Inbox' 181 | : props.type === 'draft' 182 | ? 'Drafts' 183 | : 'Sent'} 184 |

185 |
186 | 187 |
188 | 189 |
190 | { 192 | removeEmailFromList(messageID) 193 | }} 194 | /> 195 |
196 | 197 |
198 | 199 |
200 | 201 | ) 202 | } 203 | 204 | export function useInboxContext() { 205 | return useOutletContext() 206 | } 207 | -------------------------------------------------------------------------------- /web/src/pages/Root.tsx: -------------------------------------------------------------------------------- 1 | import { Bars3Icon } from '@heroicons/react/24/outline' 2 | import { useReducer, useRef, useState } from 'react' 3 | import { Outlet } from 'react-router' 4 | 5 | import { Toaster } from '@ui/sonner' 6 | 7 | import Sidebar from 'components/Sidebar' 8 | 9 | import { 10 | ConfigContext, 11 | configReducer, 12 | initialConfigState 13 | } from 'contexts/ConfigContext' 14 | import { 15 | DraftEmailsContext, 16 | draftEmailReducer, 17 | initialState 18 | } from 'contexts/DraftEmailContext' 19 | 20 | import { useOutsideClick } from 'hooks/useOutsideClick' 21 | 22 | export default function Root() { 23 | const [configState, configDispatch] = useReducer( 24 | configReducer, 25 | initialConfigState 26 | ) 27 | const [draftEmailsState, draftEmailsDispatch] = useReducer( 28 | draftEmailReducer, 29 | initialState 30 | ) 31 | 32 | const [sidebarOnMobile, setSidebarOnMobile] = useState(false) 33 | const mobileSidebarRef = useRef(null) 34 | useOutsideClick([mobileSidebarRef], () => { 35 | setSidebarOnMobile(false) 36 | }) 37 | 38 | return ( 39 | 45 | 52 |
53 | 54 |
55 | 56 |
62 | 63 |
64 | 65 | {/* sidebar on mobile - absolute positioning */} 66 | {sidebarOnMobile ? ( 67 | 68 |
69 | 70 |
71 |
72 | ) : ( 73 | 74 | { 77 | setSidebarOnMobile(true) 78 | }} 79 | > 80 | 81 | 82 | 83 | )} 84 |
85 | 86 |
87 | 88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /web/src/preflight.css: -------------------------------------------------------------------------------- 1 | /* 2 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 3 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 4 | */ 5 | 6 | *, 7 | ::before, 8 | ::after { 9 | box-sizing: border-box; /* 1 */ 10 | border-width: 0; /* 2 */ 11 | border-style: solid; /* 2 */ 12 | border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */ 13 | } 14 | 15 | ::before, 16 | ::after { 17 | --tw-content: ''; 18 | } 19 | 20 | /* 21 | 1. Use a consistent sensible line-height in all browsers. 22 | 2. Prevent adjustments of font size after orientation changes in iOS. 23 | 3. Use a more readable tab size. 24 | 4. Use the user's configured `sans` font-family by default. 25 | 5. Use the user's configured `sans` font-feature-settings by default. 26 | 6. Use the user's configured `sans` font-variation-settings by default. 27 | 7. Disable tap highlights on iOS. 28 | */ 29 | 30 | html, 31 | :host { 32 | line-height: 1.5; /* 1 */ 33 | -webkit-text-size-adjust: 100%; /* 2 */ 34 | -moz-tab-size: 4; /* 3 */ 35 | tab-size: 4; /* 3 */ 36 | font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */ 37 | font-feature-settings: theme('fontFamily.sans[1].fontFeatureSettings', normal); /* 5 */ 38 | font-variation-settings: theme('fontFamily.sans[1].fontVariationSettings', normal); /* 6 */ 39 | -webkit-tap-highlight-color: transparent; /* 7 */ 40 | } 41 | 42 | /* 43 | 1. Remove the margin in all browsers. 44 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 45 | */ 46 | 47 | body { 48 | margin: 0; /* 1 */ 49 | line-height: inherit; /* 2 */ 50 | } 51 | 52 | /* 53 | 1. Add the correct height in Firefox. 54 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 55 | 3. Ensure horizontal rules are visible by default. 56 | */ 57 | 58 | hr { 59 | height: 0; /* 1 */ 60 | color: inherit; /* 2 */ 61 | border-top-width: 1px; /* 3 */ 62 | } 63 | 64 | /* 65 | Add the correct text decoration in Chrome, Edge, and Safari. 66 | */ 67 | 68 | abbr:where([title]) { 69 | text-decoration: underline dotted; 70 | } 71 | 72 | /* 73 | Remove the default font size and weight for headings. 74 | */ 75 | 76 | h1, 77 | h2, 78 | h3, 79 | h4, 80 | h5, 81 | h6 { 82 | font-size: inherit; 83 | font-weight: inherit; 84 | } 85 | 86 | /* 87 | Reset links to optimize for opt-in styling instead of opt-out. 88 | */ 89 | 90 | a { 91 | color: inherit; 92 | text-decoration: inherit; 93 | } 94 | 95 | /* 96 | Add the correct font weight in Edge and Safari. 97 | */ 98 | 99 | b, 100 | strong { 101 | font-weight: bolder; 102 | } 103 | 104 | /* 105 | 1. Use the user's configured `mono` font-family by default. 106 | 2. Use the user's configured `mono` font-feature-settings by default. 107 | 3. Use the user's configured `mono` font-variation-settings by default. 108 | 4. Correct the odd `em` font sizing in all browsers. 109 | */ 110 | 111 | code, 112 | kbd, 113 | samp, 114 | pre { 115 | font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); /* 1 */ 116 | font-feature-settings: theme('fontFamily.mono[1].fontFeatureSettings', normal); /* 2 */ 117 | font-variation-settings: theme('fontFamily.mono[1].fontVariationSettings', normal); /* 3 */ 118 | font-size: 1em; /* 4 */ 119 | } 120 | 121 | /* 122 | Add the correct font size in all browsers. 123 | */ 124 | 125 | small { 126 | font-size: 80%; 127 | } 128 | 129 | /* 130 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 131 | */ 132 | 133 | sub, 134 | sup { 135 | font-size: 75%; 136 | line-height: 0; 137 | position: relative; 138 | vertical-align: baseline; 139 | } 140 | 141 | sub { 142 | bottom: -0.25em; 143 | } 144 | 145 | sup { 146 | top: -0.5em; 147 | } 148 | 149 | /* 150 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 151 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 152 | 3. Remove gaps between table borders by default. 153 | */ 154 | 155 | .preflight table { 156 | text-indent: 0; /* 1 */ 157 | border-color: inherit; /* 2 */ 158 | border-collapse: collapse; /* 3 */ 159 | } 160 | 161 | /* 162 | 1. Change the font styles in all browsers. 163 | 2. Remove the margin in Firefox and Safari. 164 | 3. Remove default padding in all browsers. 165 | */ 166 | 167 | .preflight button, 168 | .preflight input, 169 | .preflight optgroup, 170 | .preflight select, 171 | .preflight textarea { 172 | font-family: inherit; /* 1 */ 173 | font-feature-settings: inherit; /* 1 */ 174 | font-variation-settings: inherit; /* 1 */ 175 | font-size: 100%; /* 1 */ 176 | font-weight: inherit; /* 1 */ 177 | line-height: inherit; /* 1 */ 178 | color: inherit; /* 1 */ 179 | margin: 0; /* 2 */ 180 | padding: 0; /* 3 */ 181 | } 182 | 183 | /* 184 | Remove the inheritance of text transform in Edge and Firefox. 185 | */ 186 | 187 | .preflight button, 188 | .preflight select { 189 | text-transform: none; 190 | } 191 | 192 | /* 193 | 1. Correct the inability to style clickable types in iOS and Safari. 194 | 2. Remove default button styles. 195 | */ 196 | 197 | .preflight button, 198 | .preflight [type='button'], 199 | .preflight [type='reset'], 200 | .preflight [type='submit'] { 201 | -webkit-appearance: button; /* 1 */ 202 | background-color: transparent; /* 2 */ 203 | background-image: none; /* 2 */ 204 | } 205 | 206 | /* 207 | Use the modern Firefox focus style for all focusable elements. 208 | */ 209 | 210 | :-moz-focusring { 211 | outline: auto; 212 | } 213 | 214 | /* 215 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 216 | */ 217 | 218 | :-moz-ui-invalid { 219 | box-shadow: none; 220 | } 221 | 222 | /* 223 | Add the correct vertical alignment in Chrome and Firefox. 224 | */ 225 | 226 | .preflight progress { 227 | vertical-align: baseline; 228 | } 229 | 230 | /* 231 | Correct the cursor style of increment and decrement buttons in Safari. 232 | */ 233 | 234 | ::-webkit-inner-spin-button, 235 | ::-webkit-outer-spin-button { 236 | height: auto; 237 | } 238 | 239 | /* 240 | 1. Correct the odd appearance in Chrome and Safari. 241 | 2. Correct the outline style in Safari. 242 | */ 243 | 244 | .preflight [type='search'] { 245 | -webkit-appearance: textfield; /* 1 */ 246 | outline-offset: -2px; /* 2 */ 247 | } 248 | 249 | /* 250 | Remove the inner padding in Chrome and Safari on macOS. 251 | */ 252 | 253 | ::-webkit-search-decoration { 254 | -webkit-appearance: none; 255 | } 256 | 257 | /* 258 | 1. Correct the inability to style clickable types in iOS and Safari. 259 | 2. Change font properties to `inherit` in Safari. 260 | */ 261 | 262 | ::-webkit-file-upload-button { 263 | -webkit-appearance: button; /* 1 */ 264 | font: inherit; /* 2 */ 265 | } 266 | 267 | /* 268 | Add the correct display in Chrome and Safari. 269 | */ 270 | 271 | .preflight summary { 272 | display: list-item; 273 | } 274 | 275 | /* 276 | Removes the default spacing for appropriate elements. 277 | */ 278 | 279 | .preflight blockquote, 280 | .preflight dl, 281 | .preflight dd, 282 | .preflight h1, 283 | .preflight h2, 284 | .preflight h3, 285 | .preflight h4, 286 | .preflight h5, 287 | .preflight h6, 288 | .preflight hr, 289 | .preflight figure, 290 | .preflight p, 291 | .preflight pre { 292 | margin: 0; 293 | } 294 | 295 | .preflight fieldset { 296 | margin: 0; 297 | padding: 0; 298 | } 299 | 300 | .preflight legend { 301 | padding: 0; 302 | } 303 | 304 | .preflight ol, 305 | .preflight ul, 306 | .preflight menu { 307 | list-style: none; 308 | margin: 0; 309 | padding: 0; 310 | } 311 | 312 | .preflight dialog { 313 | padding: 0; 314 | } 315 | 316 | /* 317 | Prevent resizing textareas horizontally by default. 318 | */ 319 | 320 | .preflight textarea { 321 | resize: vertical; 322 | } 323 | 324 | /* 325 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 326 | 2. Set the default placeholder color to the user's configured gray 400 color. 327 | */ 328 | 329 | .preflight input::placeholder, 330 | .preflight textarea::placeholder { 331 | opacity: 1; /* 1 */ 332 | color: var(--color-gray-400, #9ca3af); /* 2 */ 333 | } 334 | 335 | /* 336 | Set the default cursor for buttons. 337 | */ 338 | 339 | .preflight button, 340 | [role="button"] { 341 | cursor: pointer; 342 | } 343 | 344 | /* 345 | Make sure disabled buttons don't get the pointer cursor. 346 | */ 347 | 348 | :disabled { 349 | cursor: default; 350 | } 351 | 352 | /* 353 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 354 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 355 | This can trigger a poorly considered lint error in some tools but is included by design. 356 | */ 357 | 358 | .preflight img, 359 | .preflight svg, 360 | .preflight video, 361 | .preflight canvas, 362 | .preflight audio, 363 | .preflight iframe, 364 | .preflight embed, 365 | .preflight object { 366 | display: block; /* 1 */ 367 | vertical-align: middle; /* 2 */ 368 | } 369 | 370 | /* 371 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 372 | */ 373 | 374 | .preflight img, 375 | .preflight video { 376 | max-width: 100%; 377 | height: auto; 378 | } 379 | 380 | /* 381 | Make elements with the HTML hidden attribute stay hidden by default. 382 | */ 383 | 384 | [hidden] { 385 | display: none; 386 | } 387 | -------------------------------------------------------------------------------- /web/src/services/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | emailAddresses: string[] 3 | disableProxy: boolean 4 | imagesAutoLoad: boolean 5 | plugins: Plugin[] 6 | } 7 | 8 | export interface Plugin { 9 | name: string 10 | displayName: string 11 | endpoints: { 12 | email: string 13 | emails: string 14 | } 15 | } 16 | 17 | export async function getConfig(): Promise { 18 | const response = await fetch('/config') 19 | return response.json() as Promise 20 | } 21 | -------------------------------------------------------------------------------- /web/src/services/emails.ts: -------------------------------------------------------------------------------- 1 | export interface EmailInfo { 2 | messageID: string 3 | type: 'inbox' | 'draft' | 'sent' 4 | timeReceived: string | null 5 | timeUpdated: string | null 6 | timeSent: string | null 7 | subject: string 8 | from: string[] 9 | to: string[] 10 | threadID: string 11 | isThreadLatest: true | undefined 12 | unread?: boolean 13 | } 14 | 15 | export interface ListEmailsProps { 16 | type: 'inbox' | 'draft' | 'sent' 17 | year?: number 18 | month?: number 19 | order?: 'asc' | 'desc' 20 | pageSize?: number 21 | nextCursor?: string 22 | } 23 | 24 | export interface ListEmailsResponse { 25 | count: number 26 | items: EmailInfo[] 27 | hasMore: boolean 28 | nextCursor?: string 29 | } 30 | 31 | export async function listEmails( 32 | props: ListEmailsProps 33 | ): Promise { 34 | const { type, year, month, order, pageSize, nextCursor } = props 35 | const params = new URLSearchParams({ 36 | type 37 | }) 38 | if (year) { 39 | params.append('year', year.toString()) 40 | } 41 | if (month) { 42 | params.append('month', month.toString()) 43 | } 44 | if (order) { 45 | params.append('order', order) 46 | } 47 | if (pageSize) { 48 | params.append('pageSize', pageSize.toString()) 49 | } 50 | if (nextCursor) { 51 | params.append('nextCursor', nextCursor) 52 | } 53 | 54 | const response = await fetch('/web/emails?' + params.toString(), { 55 | method: 'GET' 56 | }) 57 | return response.json() as Promise 58 | } 59 | 60 | export interface File { 61 | contentID: string 62 | contentType: string 63 | contentTypeParams: Record 64 | filename: string 65 | } 66 | 67 | export interface Email { 68 | messageID: string 69 | type: 'inbox' | 'draft' | 'sent' 70 | subject: string 71 | from: string[] 72 | to: string[] 73 | text: string 74 | html: string 75 | threadID?: string 76 | 77 | // inbox only 78 | timeReceived: string 79 | dateSent: string 80 | source: string 81 | destination: string[] 82 | returnPath: string 83 | verdict: EmailVerdict 84 | unread?: boolean 85 | 86 | // draft only 87 | timeUpdated: string 88 | cc: string[] 89 | bcc: string[] 90 | replyTo: string[] 91 | 92 | attachments: File[] 93 | inlines: File[] 94 | otherParts?: File[] 95 | 96 | // sent only 97 | timeSent: string 98 | } 99 | 100 | export interface EmailVerdict { 101 | spam: boolean 102 | dkim: boolean 103 | dmarc: boolean 104 | spf: boolean 105 | virus: boolean 106 | } 107 | 108 | export async function getEmail(id: string): Promise { 109 | const response = await fetch(`/web/emails/${id}`) 110 | return response.json() as Promise 111 | } 112 | 113 | export interface CreateEmailProps { 114 | subject: string 115 | from: string[] 116 | to: string[] 117 | cc: string[] 118 | bcc: string[] 119 | replyTo: string[] 120 | text: string 121 | html: string 122 | send: boolean 123 | replyEmailID?: string 124 | } 125 | 126 | export async function getEmailRaw(messageID: string): Promise { 127 | const response = await fetch(`/web/emails/${messageID}/raw`) 128 | return response.text() 129 | } 130 | 131 | export async function createEmail(email: CreateEmailProps): Promise { 132 | // TODO: should return error 133 | const response = await fetch('/web/emails', { 134 | method: 'POST', 135 | headers: { 136 | 'Content-Type': 'application/json' 137 | }, 138 | body: JSON.stringify({ 139 | ...email, 140 | generateText: 'off' 141 | }) 142 | }) 143 | return response.json() as Promise 144 | } 145 | 146 | export type SaveEmailProps = CreateEmailProps & { 147 | messageID: string 148 | } 149 | 150 | export async function saveEmail(email: SaveEmailProps): Promise { 151 | const response = await fetch(`/web/emails/${email.messageID}`, { 152 | method: 'PUT', 153 | headers: { 154 | 'Content-Type': 'application/json' 155 | }, 156 | body: JSON.stringify({ 157 | subject: email.subject, 158 | from: email.from, 159 | to: email.to, 160 | cc: email.cc, 161 | bcc: email.bcc, 162 | replyTo: email.replyTo, 163 | text: email.text, 164 | html: email.html, 165 | send: email.send 166 | }) 167 | }) 168 | return response.json() as Promise 169 | } 170 | 171 | export async function deleteEmail(messageID: string): Promise { 172 | await fetch(`/web/emails/${messageID}`, { 173 | method: 'DELETE' 174 | }) 175 | } 176 | 177 | export async function trashEmail(messageID: string): Promise { 178 | await fetch(`/web/emails/${messageID}/trash`, { 179 | method: 'POST' 180 | }) 181 | } 182 | 183 | export async function readEmail(messageID: string): Promise { 184 | await fetch(`/web/emails/${messageID}/read`, { 185 | method: 'POST' 186 | }) 187 | } 188 | 189 | export async function unreadEmail(messageID: string): Promise { 190 | await fetch(`/web/emails/${messageID}/unread`, { 191 | method: 'POST' 192 | }) 193 | } 194 | 195 | export async function reparseEmail(messageID: string): Promise { 196 | await fetch(`/web/emails/${messageID}/reparse`, { 197 | method: 'POST' 198 | }) 199 | } 200 | 201 | export function generateLocalDraftID(): string { 202 | return `local-${Date.now().toString()}` 203 | } 204 | 205 | export function isLocalDraftID(id: string): boolean { 206 | return id.startsWith('local-') 207 | } 208 | -------------------------------------------------------------------------------- /web/src/services/info.ts: -------------------------------------------------------------------------------- 1 | interface Info { 2 | build: string 3 | commit: string 4 | version: string 5 | } 6 | 7 | export async function getInfo(): Promise { 8 | const response = await fetch('/web/info') 9 | return response.json() as Promise 10 | } 11 | -------------------------------------------------------------------------------- /web/src/services/plugins.ts: -------------------------------------------------------------------------------- 1 | export async function invoke(pluginName: string, emailIDs: string[]) { 2 | await fetch(`/plugins/invoke`, { 3 | method: 'POST', 4 | headers: { 5 | 'Content-Type': 'application/json' 6 | }, 7 | body: JSON.stringify({ 8 | name: pluginName, 9 | messageIDs: emailIDs 10 | }) 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /web/src/services/threads.ts: -------------------------------------------------------------------------------- 1 | import { Email } from './emails' 2 | 3 | export interface Thread { 4 | threadID: string 5 | type: 'thread' 6 | subject: string 7 | emailIDs: string[] 8 | draftID?: string 9 | timeUpdated: string 10 | emails: Email[] 11 | draft?: Email 12 | } 13 | 14 | export async function getThread(threadID: string): Promise { 15 | const response = await fetch(`/web/threads/${threadID}`, { 16 | method: 'GET' 17 | }) 18 | return response.json() as Promise 19 | } 20 | -------------------------------------------------------------------------------- /web/src/utils/elements.ts: -------------------------------------------------------------------------------- 1 | // Tags that are allowed in email. 2 | export const allowedTags = [ 3 | 'a', 4 | 'abbr', 5 | 'acronym', 6 | 'address', 7 | 'area', 8 | 'b', 9 | 'bdo', 10 | 'big', 11 | 'blockquote', 12 | 'br', 13 | 'button', 14 | 'caption', 15 | 'center', 16 | 'cite', 17 | 'code', 18 | 'col', 19 | 'colgroup', 20 | 'dd', 21 | 'del', 22 | 'dfn', 23 | 'dir', 24 | 'div', 25 | 'dl', 26 | 'dt', 27 | 'dd', 28 | 'em', 29 | 'fieldset', 30 | 'font', 31 | 'form', 32 | 'h1', 33 | 'h2', 34 | 'h3', 35 | 'h4', 36 | 'h5', 37 | 'h6', 38 | 'hr', 39 | 'i', 40 | 'img', 41 | 'input', 42 | 'ins', 43 | 'kbd', 44 | 'label', 45 | 'legend', 46 | 'li', 47 | 'map', 48 | 'menu', 49 | 'ol', 50 | 'optgroup', 51 | 'option', 52 | 'p', 53 | 'pre', 54 | 'q', 55 | 's', 56 | 'samp', 57 | 'select', 58 | 'small', 59 | 'span', 60 | 'strike', 61 | 'strong', 62 | 'sub', 63 | 'sup', 64 | 'table', 65 | 'tbody', 66 | 'td', 67 | 'textarea', 68 | 'tfoot', 69 | 'th', 70 | 'thead', 71 | 'u', 72 | 'tr', 73 | 'tt', 74 | 'u', 75 | 'ul', 76 | 'var', 77 | 'html', 78 | 'head', 79 | 'body', 80 | 'meta', 81 | 'style', 82 | 'link' 83 | ] 84 | 85 | // Tags that should be removed from the document, without logging a warning. 86 | export const silenceTags = ['title', 'script'] 87 | 88 | // Attributes that are allowed on all tags. 89 | export const globalAttributes = [ 90 | 'accesskey', 91 | 'class', 92 | 'contenteditable', 93 | 'dir', 94 | 'draggable', 95 | 'enterkeyhint', 96 | 'hidden', 97 | 'id', 98 | 'inert', 99 | 'inputmode', 100 | 'lang', 101 | 'popover', 102 | 'spellcheck', 103 | 'style', 104 | 'tabindex', 105 | 'title', 106 | 'translate' 107 | ] 108 | 109 | // Attributes that are allowed on `` tags. 110 | export const imgAttributes = [ 111 | 'alt', 112 | 'crossorigin', 113 | 'height', 114 | 'ismap', 115 | 'loading', 116 | 'longdesc', 117 | 'referrerpolicy', 118 | 'sizes', 119 | 'src', 120 | 'srcset', 121 | 'usemap', 122 | 'width' 123 | ] 124 | -------------------------------------------------------------------------------- /web/src/utils/emails.test.ts: -------------------------------------------------------------------------------- 1 | import { exportedForTesting, parseEmailName } from './emails' 2 | 3 | describe('parseEmailName', () => { 4 | it('simple case', () => { 5 | const result = parseEmailName(['FirstName ']) 6 | expect(result.name).toBe('FirstName') 7 | expect(result.address).toBe('foo@example.com') 8 | }) 9 | 10 | it('multiple space', () => { 11 | const result = parseEmailName(['FirstName \u003cfoo@example.com\u003e']) 12 | expect(result.name).toBe('FirstName') 13 | expect(result.address).toBe('foo@example.com') 14 | }) 15 | 16 | it('trim space', () => { 17 | const result = parseEmailName(['< foo@example.com > ']) 18 | expect(result.name).toBeNull() 19 | expect(result.address).toBe('foo@example.com') 20 | }) 21 | 22 | it('without name, always address', () => { 23 | const result = parseEmailName(['']) 24 | expect(result.name).toBeNull() 25 | expect(result.address).toBe('foo@example.com') 26 | }) 27 | }) 28 | 29 | const { makeCSSURL } = exportedForTesting 30 | 31 | describe('makeCSSURL', () => { 32 | it('should return a string', () => { 33 | const result = makeCSSURL('https://proxy.com', 'test') 34 | expect(typeof result).toBe('string') 35 | }) 36 | 37 | it('arbitrary value without url function', () => { 38 | const result = makeCSSURL('https://proxy.com', 'test') 39 | expect(result).toBe('test') 40 | }) 41 | 42 | it('url function with no quotes', () => { 43 | const result = makeCSSURL( 44 | 'https://proxy.com', 45 | 'url(https://example.com/image.png)' 46 | ) 47 | expect(result).toBe( 48 | 'url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage.png)' 49 | ) 50 | }) 51 | 52 | it('url function with single quotes', () => { 53 | const result = makeCSSURL( 54 | 'https://proxy.com', 55 | "url('https://example.com/image.png')" 56 | ) 57 | expect(result).toBe( 58 | 'url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage.png)' 59 | ) 60 | }) 61 | 62 | it('url function with double quotes', () => { 63 | const result = makeCSSURL( 64 | 'https://proxy.com', 65 | 'url("https://example.com/image.png")' 66 | ) 67 | expect(result).toBe( 68 | 'url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage.png)' 69 | ) 70 | }) 71 | 72 | it('url function with spaces', () => { 73 | const result = makeCSSURL( 74 | 'https://proxy.com', 75 | 'url( https://example.com/image.png )' 76 | ) 77 | expect(result).toBe( 78 | 'url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage.png)' 79 | ) 80 | }) 81 | 82 | it('multiple url functions', () => { 83 | const result = makeCSSURL( 84 | 'https://proxy.com', 85 | 'url(https://example.com/image.png) url(https://example.com/image2.png)' 86 | ) 87 | expect(result).toBe( 88 | 'url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage.png) url(https://proxy.com/proxy?l=https%3A%2F%2Fexample.com%2Fimage2.png)' 89 | ) 90 | }) 91 | 92 | it('url function with data uri', () => { 93 | const result = makeCSSURL( 94 | 'https://proxy.com', 95 | 'data://image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wr+9OAAAAABJRU5ErkJggg==' 96 | ) 97 | expect(result).toBe( 98 | 'data://image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wr+9OAAAAABJRU5ErkJggg==' 99 | ) 100 | }) 101 | 102 | it("doesn't match", () => { 103 | const result = makeCSSURL( 104 | 'https://proxy.com', 105 | 'linear-gradient(#cc0000, #cc0000)' 106 | ) 107 | expect(result).toBe('linear-gradient(#cc0000, #cc0000)') 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /web/src/utils/emails.tsx: -------------------------------------------------------------------------------- 1 | import * as css from '@adobe/css-tools' 2 | import parse, { 3 | DOMNode, 4 | Element, 5 | HTMLReactParserOptions, 6 | Text, 7 | domToReact 8 | } from 'html-react-parser' 9 | 10 | import { type Email, type File } from 'services/emails' 11 | 12 | import { 13 | allowedTags, 14 | globalAttributes, 15 | imgAttributes, 16 | silenceTags 17 | } from 'utils/elements' 18 | 19 | export function parseEmailName(emails: string[] | null): { 20 | name: string | null 21 | address: string | null 22 | } { 23 | if (!emails || emails.length === 0) { 24 | return { name: null, address: null } 25 | } 26 | 27 | const regex = /(.*?)<(.*?)>/g 28 | const match = regex.exec(emails[0]) 29 | if (!match) return { name: null, address: emails[0] } 30 | const name = match[1].trim() 31 | const address = match[2].trim() 32 | return { 33 | name: name === '' ? null : name, 34 | address: address === '' ? null : address 35 | } 36 | } 37 | 38 | export function parseEmailContent( 39 | email: Email, 40 | disableProxy?: boolean, 41 | loadImage?: boolean 42 | ) { 43 | if (!email.html) return email.text 44 | 45 | const host = `${window.location.protocol}//${window.location.host}` 46 | 47 | const options: HTMLReactParserOptions = { 48 | replace: (domNode: DOMNode) => { 49 | if (!(domNode instanceof Element)) return 50 | if (['html', 'head', 'body'].includes(domNode.name)) { 51 | return <>{domToReact(domNode.children as DOMNode[], options)} 52 | } 53 | if (!allowedTags.includes(domNode.name)) { 54 | if (!silenceTags.includes(domNode.name)) { 55 | console.warn(`Unsupported tag: ${domNode.name}`) 56 | } 57 | return <> 58 | } 59 | if (domNode.name === 'a') { 60 | domNode.attribs.target = '_blank' 61 | domNode.attribs.rel = 'noopener noreferrer' 62 | return 63 | } 64 | 65 | // handle inline styles 66 | if (domNode.attribs.style) { 67 | domNode.attribs.style = transformStyles(host, domNode.attribs.style) 68 | } 69 | 70 | if (domNode.name === 'style') { 71 | domNode.children = domNode.children 72 | .map((child) => { 73 | // nodeType 3 is text in domhandler package 74 | if (child.nodeType !== 3) return null 75 | return new Text(transformCss(host, child.data)) 76 | }) 77 | .filter((child) => child !== null) 78 | } 79 | if (domNode.name === 'img' && domNode.attribs.src) { 80 | if (domNode.attribs.src.startsWith('cid:')) { 81 | const cid = domNode.attribs.src.replace('cid:', '') 82 | let disposition = '' 83 | if (containContentID(email.attachments, cid)) { 84 | disposition = 'attachments' 85 | } else if (containContentID(email.inlines, cid)) { 86 | disposition = 'inlines' 87 | } else if (containContentID(email.otherParts, cid)) { 88 | disposition = 'others' 89 | } 90 | 91 | if (disposition !== '') { 92 | domNode.attribs.src = `${window.location.origin}/web/emails/${email.messageID}/${disposition}/${cid}` 93 | } 94 | } else { 95 | if (!loadImage) { 96 | domNode.attribs.src = '' 97 | } else if (!disableProxy) { 98 | domNode.attribs['data-original-src'] = domNode.attribs.src 99 | domNode.attribs.src = makeProxyURL(host, domNode.attribs.src) 100 | } 101 | } 102 | 103 | domNode.attribs = filterElementAttributes(domNode.name, domNode.attribs) 104 | } 105 | } 106 | } 107 | const element = parse(email.html, options) 108 | if (Array.isArray(element)) { 109 | return <>{element} 110 | } else if (typeof element === 'string') { 111 | return element 112 | } 113 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 114 | if (element.props.children) { 115 | return element 116 | } 117 | // fallback to text if html parsing fails 118 | return ( 119 |
120 |       {email.text}
121 |     
122 | ) 123 | } 124 | 125 | function filterElementAttributes( 126 | domName: Element['name'], 127 | attribs: Element['attribs'] 128 | ): Element['attribs'] { 129 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 130 | if (attribs === undefined) return attribs 131 | if (domName === 'img') { 132 | return Object.fromEntries( 133 | Object.entries(attribs).filter(([key]) => { 134 | if (globalAttributes.includes(key)) return true 135 | if (imgAttributes.includes(key)) return true 136 | return key.startsWith('data-') 137 | }) 138 | ) 139 | } 140 | return attribs 141 | } 142 | 143 | // containContentID returns true if there is a file with the given contentID 144 | function containContentID(files: File[] | undefined, cid: string) { 145 | if (files === undefined) return false 146 | return files.some((file) => file.contentID === cid) 147 | } 148 | 149 | export function parseEmailHTML(html: string) { 150 | const email = { html } as Email 151 | return parseEmailContent(email) 152 | } 153 | 154 | function transformStyles(host: string, styles: string) { 155 | styles = styles.trim() 156 | let styleParts = styles.split(';') 157 | styleParts = styleParts.map((part) => { 158 | if (part.length === 0) return part 159 | 160 | const split = part.split(':') 161 | const property = split[0].trim() 162 | const value = split.slice(1).join(':') 163 | if (!property || !value) return part 164 | 165 | if (isURLProperty(property)) { 166 | const transformedValue = makeCSSURL(host, value) 167 | return `${property}:${transformedValue}` 168 | } 169 | 170 | return part 171 | }) 172 | 173 | return styleParts.join(';') 174 | } 175 | 176 | // transformCss transforms css to be scoped to the email-sandbox class 177 | function transformCss(host: string, code: string) { 178 | const obj = css.parse(code, { silent: true }) 179 | 180 | const cssRules = transformCssRules(host, obj.stylesheet.rules) 181 | if (cssRules) obj.stylesheet.rules = cssRules 182 | const result = css.stringify(obj, { compress: false }) 183 | 184 | return result 185 | } 186 | 187 | function transformCssRules(host: string, rules?: css.CssAtRuleAST[]) { 188 | const replaceDeclarations = ( 189 | declarations: (css.CssCommentAST | css.CssDeclarationAST)[] 190 | ) => { 191 | return declarations.map((declaration) => { 192 | if (declaration.type === css.CssTypes.declaration) { 193 | if (isURLProperty(declaration.property)) { 194 | declaration.value = makeCSSURL(host, declaration.value) 195 | } 196 | } 197 | return declaration 198 | }) 199 | } 200 | 201 | return rules?.map((rule) => { 202 | if (rule.type === css.CssTypes.rule) { 203 | rule.selectors = rule.selectors.map((selector) => { 204 | if (selector.startsWith('@')) { 205 | return selector 206 | } 207 | return selector.includes('.email-sandbox') 208 | ? selector 209 | : `.email-sandbox ${selector}` 210 | }) 211 | rule.declarations = replaceDeclarations(rule.declarations) 212 | } else if (rule.type === css.CssTypes.fontFace) { 213 | rule.declarations = replaceDeclarations(rule.declarations) 214 | } else if ('rules' in rule) { 215 | rule.rules = transformCssRules(host, rule.rules) 216 | } 217 | return rule 218 | }) 219 | } 220 | 221 | function makeProxyURL(host: string, url: string) { 222 | if (!url) return url 223 | if (url.startsWith('data:')) return url 224 | url = url.trim() 225 | return `${host}/proxy?l=${encodeURIComponent(url)}` 226 | } 227 | 228 | // isURLProperty returns true if the CSS property may have url function 229 | function isURLProperty(property: string) { 230 | const watchProperties = [ 231 | 'background', 232 | 'background-image', 233 | 'border', 234 | 'border-image', 235 | 'border-image-source', 236 | 'content', 237 | 'cursor', 238 | 'filter', 239 | 'list-style', 240 | 'list-style-image', 241 | 'mask', 242 | 'mask-image', 243 | 'offset-path', 244 | 'src' 245 | ] 246 | 247 | return watchProperties.includes(property.toLowerCase()) 248 | } 249 | 250 | function makeCSSURL(host: string, value: string) { 251 | return value.replace(/url\( *['"]?(.*?)['"]? *\)/g, (match, url: string) => { 252 | if (url.startsWith('https://') || url.startsWith('http://')) { 253 | return `url(${makeProxyURL(host, url)})` 254 | } 255 | return match 256 | }) 257 | } 258 | 259 | export const exportedForTesting = { 260 | makeCSSURL 261 | } 262 | -------------------------------------------------------------------------------- /web/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function getCurrentYearMonth(): { 2 | year: number 3 | month: number 4 | } { 5 | const date = new Date() 6 | return { 7 | year: date.getUTCFullYear(), 8 | month: date.getUTCMonth() + 1 9 | } 10 | } 11 | 12 | export function formatDate( 13 | date: string, 14 | { 15 | short = false, 16 | monthDayOnly = false 17 | }: { short?: boolean; monthDayOnly?: boolean } = {} 18 | ): string { 19 | if (!date) return '' 20 | if (short) return formatDateShort(date) 21 | return formatDateLong(date, monthDayOnly) 22 | } 23 | 24 | function formatDateShort(date: string): string { 25 | const dateObj = new Date(date) 26 | const now = new Date() 27 | 28 | // If the date is today, show the time 29 | if ( 30 | dateObj.getFullYear() == now.getFullYear() && 31 | dateObj.getMonth() == now.getMonth() && 32 | dateObj.getDate() == now.getDate() 33 | ) { 34 | const hour = dateObj.getHours() 35 | const minutesStr = dateObj.getMinutes().toString().padStart(2, '0') 36 | const meridian = hour >= 12 ? 'PM' : 'AM' 37 | return `${(hour % 12 || 12).toString()}:${minutesStr} ${meridian}` 38 | } 39 | 40 | // If the date is in current year, show the month and day 41 | if (dateObj.getFullYear() == now.getFullYear()) { 42 | const month = dateObj.toLocaleString('default', { month: 'short' }) 43 | const day = dateObj.getDate() 44 | return `${month} ${day.toString()}` 45 | } 46 | 47 | // Otherwise, show the full date 48 | let year = dateObj.getFullYear() 49 | if (year > 2000) year -= 2000 // Show 2-digit year 50 | const month = dateObj.getMonth() + 1 51 | const day = dateObj.getDate() 52 | return `${month.toString()}/${day.toString()}/${year.toString()}` 53 | } 54 | 55 | function formatDateLong(date: string, monthDayOnly: boolean): string { 56 | const dateObj = new Date(date) 57 | 58 | const month = dateObj.toLocaleString('default', { month: 'short' }) 59 | const day = dateObj.getDate() 60 | if (monthDayOnly) return `${month} ${day.toString()}` 61 | 62 | const year = dateObj.getFullYear() 63 | let hour = dateObj.getHours() 64 | const minutesStr = dateObj.getMinutes().toString().padStart(2, '0') 65 | const meridian = hour >= 12 ? 'PM' : 'AM' 66 | hour = hour % 12 || 12 // Convert 0 to 12 67 | return `${month} ${day.toString()}, ${year.toString()}, ${hour.toString()}:${minutesStr} ${meridian}` 68 | } 69 | 70 | export function formatDateFull(date: string): string { 71 | const dateObj = new Date(date) 72 | const dayOfWeek = dateObj.toLocaleString('default', { weekday: 'short' }) 73 | 74 | return `${dayOfWeek}, ${formatDateLong(date, false)}` 75 | } 76 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "*": ["./src/*"], 21 | "@/*": ["./src/*"], 22 | "@ui/*": ["./src/components/ui/*"] 23 | } 24 | }, 25 | "include": ["src", "tailwind.config.ts", "jest.config.ts"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | import { defineConfig } from 'vite' 5 | import tsconfigPaths from 'vite-tsconfig-paths' 6 | 7 | const proxyRoutes = ['/web', '/proxy', '/config', '/plugins'] 8 | 9 | const proxy = {} as Record< 10 | string, 11 | { 12 | target: string 13 | changeOrigin: boolean 14 | } 15 | > 16 | 17 | proxyRoutes.forEach((route) => { 18 | proxy[route] = { 19 | target: 'http://localhost:8070', 20 | changeOrigin: true 21 | } 22 | }) 23 | 24 | // https://vitejs.dev/config/ 25 | export default defineConfig({ 26 | server: { 27 | proxy 28 | }, 29 | plugins: [react(), tailwindcss(), tsconfigPaths()], 30 | resolve: { 31 | alias: { 32 | '@ui': path.resolve(__dirname, './src/components/ui') 33 | } 34 | } 35 | }) 36 | --------------------------------------------------------------------------------